mirror of
https://github.com/lukaszraczylo/go-simple-graphql.git
synced 2026-10-12 00:45:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489247c42a | ||
|
|
6c55bccc20 | ||
|
|
97cf896a17 | ||
|
|
71a14721c5 | ||
|
|
cd16b92dca | ||
|
|
b314a25549 | ||
|
|
240929a808 | ||
|
|
5136a5869b | ||
|
|
5da571351d | ||
|
|
dc3ea548c4 | ||
|
|
7b4f48e2bc | ||
|
|
acd562f070 | ||
|
|
37fc41d8ff | ||
|
|
85cca6ec92 | ||
|
|
7daa6a4577 | ||
|
|
8c2c6068c5 | ||
|
|
067841471f | ||
|
|
4d4eac8a20 | ||
|
|
ad28eea7af | ||
|
|
9ddd3ea1cf | ||
|
|
26bdc5e056 | ||
|
|
9d99ce710e | ||
|
|
bee48dfd22 | ||
|
|
fc076412a3 | ||
|
|
359bff71b1 | ||
|
|
25252ac30f | ||
|
|
1c56b9fbb4 | ||
|
|
5030fec4ff | ||
|
|
3d89a77565 | ||
|
|
4a4b24fa45 | ||
|
|
d2453fe2f5 | ||
|
|
28eec46f4c | ||
|
|
63e449ef77 | ||
|
|
50cfc5f98d | ||
|
|
4a575ac9c8 | ||
|
|
f54cedaeb3 | ||
|
|
1080083384 | ||
|
|
941aadb869 | ||
|
|
8174ae6f9c | ||
|
|
992a29da85 | ||
|
|
ecf2c06401 | ||
|
|
fb0785229c | ||
|
|
8ac446db41 | ||
|
|
586af64d70 | ||
|
|
50c1033ad1 | ||
|
|
7a791965ca | ||
|
|
05b057c67c | ||
|
|
9c729d814a | ||
|
|
9d5e2122b8 | ||
|
|
a19f1d257a | ||
|
|
563c952c78 | ||
|
|
2f259a4b3f | ||
|
|
957857537a | ||
|
|
4ea4bbb6cc | ||
|
|
36ef16ad3d | ||
|
|
42170f9b8e | ||
|
|
4f438b7eac | ||
|
|
569867d04b | ||
|
|
9a58df7a54 | ||
|
|
4e60a90ce4 | ||
|
|
2f4c8cc5d8 | ||
|
|
154233df14
|
||
|
|
29afbd08fd
|
||
|
|
a107ee953b
|
||
|
|
58094f85c7
|
||
|
|
ac957ac95b
|
||
|
|
ef4ab45424
|
||
|
|
4fc826c1f6
|
||
|
|
3217edf37f
|
||
|
|
ae96059f61
|
||
|
|
2ce7016d7e
|
||
|
|
9da7c9bec9
|
||
|
|
ca13be5ef6
|
||
|
|
1df2ae5d16
|
||
|
|
39f489210b
|
||
|
|
21c9119bcb
|
||
|
|
a7b385f0b7
|
||
|
|
7d3abf5464
|
||
|
|
7af4e1f96a
|
||
|
|
7623410bfe
|
||
|
|
8e26d79df7
|
||
|
|
aa25c43795
|
||
|
|
dfb082ad51
|
||
|
|
ce736958c6
|
||
|
|
4472041b21
|
||
|
|
b81e612f51
|
||
|
|
3c508eaf15
|
||
|
|
4d0c04cb26
|
||
|
|
55182f2f3d
|
||
|
|
841c136e71
|
||
|
|
833517f35c
|
||
|
|
07e4f75a0a
|
||
|
|
b8044c5d67
|
||
|
|
cf3076d701
|
||
|
|
5a222aa316
|
||
|
|
d843238d46
|
||
|
|
b9cd614ae4
|
||
|
|
eef4e39611
|
||
|
|
d764980390
|
||
|
|
689b1efc43
|
||
|
|
23f88a91d2
|
||
|
|
664a692c2d
|
||
|
|
2a628a7da7
|
||
|
|
e0913f8824
|
||
|
|
6a8f99f6ef
|
||
|
|
a5c4643a5e
|
||
|
|
42e1afbf77
|
||
|
|
9e8df7e297
|
||
|
|
405d159eb9
|
||
|
|
b3e594fc4f
|
||
|
|
32d9e0bd80
|
||
|
|
e706b07488
|
||
|
|
32091fc8cd
|
||
|
|
edc695fe2b
|
||
|
|
bd0c5ec373
|
||
|
|
952749d7ff
|
||
|
|
277ef21263
|
||
|
|
4980aeb28a
|
||
|
|
91f65ebd46
|
||
|
|
d5655ae2fa
|
||
|
|
9b65ba069f
|
||
|
|
feed2f36c2
|
||
|
|
966d9ccd0a
|
||
|
|
3232498e94
|
||
|
|
3cda8e267b
|
||
|
|
1393f4d7cf
|
||
|
|
d4328fdbb7
|
||
|
|
4ac9f132a2
|
||
|
|
fb04e68b8d
|
||
|
|
40652c3fdd
|
||
|
|
f360a5cf7e
|
||
|
|
2b09da7685
|
||
|
|
aac6ca113d
|
||
|
|
c8ed2290b4
|
||
|
|
d1406eeac8
|
||
|
|
ea7ec683ef
|
||
|
|
5cd22318f1
|
||
|
|
13955da995
|
||
|
|
cd5d75feff
|
@@ -0,0 +1,73 @@
|
|||||||
|
name: Autoupdate go.mod and go.sum
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: ">=1.21"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# This job is responsible for preparation of the build
|
||||||
|
# environment variables.
|
||||||
|
prepare:
|
||||||
|
name: Preparing build context
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
id: cache
|
||||||
|
with:
|
||||||
|
go-version: ${{env.GO_VERSION}}
|
||||||
|
cache-dependency-path: "**/*.sum"
|
||||||
|
|
||||||
|
- name: Go get dependencies
|
||||||
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
go get ./...
|
||||||
|
|
||||||
|
# This job is responsible for running tests and linting the codebase
|
||||||
|
test:
|
||||||
|
name: "Unit testing"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: golang:1
|
||||||
|
needs: [prepare]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Ensure full history is checked out
|
||||||
|
token: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{env.GO_VERSION}}
|
||||||
|
cache-dependency-path: "**/*.sum"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install ca-certificates make -y
|
||||||
|
update-ca-certificates
|
||||||
|
go mod tidy
|
||||||
|
go get -u -v ./...
|
||||||
|
go mod tidy -v
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: |
|
||||||
|
CI_RUN=${CI} make test
|
||||||
|
git config --global --add safe.directory /__w/go-simple-graphql/go-simple-graphql
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
uses: stefanzweifel/git-auto-commit-action@v5
|
||||||
|
with:
|
||||||
|
commit_message: "Update go.mod and go.sum"
|
||||||
|
commit_options: "--no-verify --signoff"
|
||||||
|
file_pattern: "go.mod go.sum"
|
||||||
@@ -12,6 +12,6 @@ jobs:
|
|||||||
shared:
|
shared:
|
||||||
uses: telegram-bot-app/ci-scripts/.github/workflows/library-test-publish.yaml@main
|
uses: telegram-bot-app/ci-scripts/.github/workflows/library-test-publish.yaml@main
|
||||||
with:
|
with:
|
||||||
enable-code-scans: true
|
enable-code-scans: false
|
||||||
secrets:
|
secrets:
|
||||||
ghcr-token: ${{ secrets.GHCR_TOKEN }}
|
ghcr-token: ${{ secrets.GHCR_TOKEN }}
|
||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
*.out
|
*.out
|
||||||
*.test
|
*.test
|
||||||
coverage.txt
|
coverage.txt
|
||||||
|
*.DS_Store
|
||||||
@@ -11,11 +11,23 @@ help: ## display this help
|
|||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: ## run tests on library
|
test: ## run tests on library
|
||||||
@go test $(ADDITIONAL_BUILD_FLAGS) -v -cover ./...
|
@LOG_LEVEL=debug go test $(ADDITIONAL_BUILD_FLAGS) -v -cover ./... -race
|
||||||
|
|
||||||
.PHONY: test-packages
|
.PHONY: test-packages
|
||||||
test-packages: ## run tests on packages
|
test-packages: ## run tests on packages
|
||||||
@go test -v -cover ./pkg/...
|
@go test -v -cover ./pkg/...
|
||||||
|
|
||||||
.PHONY: all
|
.PHONY: all
|
||||||
all: test-packages test
|
all: test-packages test
|
||||||
|
|
||||||
|
.PHONY: update
|
||||||
|
update: ## update dependencies
|
||||||
|
@go get -u -v ./...
|
||||||
|
@go mod tidy -v
|
||||||
|
|
||||||
|
sonar:
|
||||||
|
sonar-scanner \
|
||||||
|
-Dsonar.projectKey=golang-simple-graphql \
|
||||||
|
-Dsonar.sources=. \
|
||||||
|
-Dsonar.host.url=http://localhost:9000 \
|
||||||
|
-Dsonar.login=555ba78b315faa07b3231a3be0f3cbb24ab33814
|
||||||
@@ -9,27 +9,162 @@ Ps. It's Hasura friendly.
|
|||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Usage example](#usage-example)
|
- [Usage example](#usage-example)
|
||||||
- [Environment variables](#environment-variables)
|
- [Environment variables](#environment-variables)
|
||||||
|
- [Modifiers on the fly](#modifiers-on-the-fly)
|
||||||
|
- [Cache](#cache)
|
||||||
- [Example reader code](#example-reader-code)
|
- [Example reader code](#example-reader-code)
|
||||||
|
- [Tips](#tips)
|
||||||
- [Working with results](#working-with-results)
|
- [Working with results](#working-with-results)
|
||||||
|
|
||||||
## Reasoning
|
## Reasoning
|
||||||
|
|
||||||
I've tried to run few graphQL clients with hasura, all of them required conversion of the data into
|
I've tried to run a few GraphQL clients with Hasura, all of them required conversion of the data into
|
||||||
the appropriate structures, causing issues with non-existing types ( thanks to Hasura ), for example `bigint` which was difficult to export.
|
the appropriate structures, causing issues with non-existing types ( thanks to Hasura ), for example, `bigint` which was difficult to export.
|
||||||
Therefore, I present you the simple client to which you can copy & paste your graphQL query, variables and you are good to go.
|
Therefore, I present you the simple client to which you can copy & paste your graphQL query, variables and you are good to go.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Executing GraphQL queries as they are, without types declaration
|
* Executing GraphQL queries as they are, without types declaration
|
||||||
* HTTP2 support!
|
* HTTP/2 support for HTTPS endpoints (automatic)
|
||||||
|
* HTTP/1.1 for HTTP endpoints (compatible with all servers)
|
||||||
|
* Automatic retry with configurable error patterns
|
||||||
* Support for additional headers
|
* Support for additional headers
|
||||||
|
* Query cache with compression
|
||||||
|
|
||||||
## Usage example
|
## Usage example
|
||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
* `GRAPHQL_ENDPOINT` - Your GraphQL endpoint. Default: `http://127.0.0.1:9090/v1/graphql`
|
* `GRAPHQL_ENDPOINT` - Your GraphQL endpoint. Default: `http://127.0.0.1:9090/v1/graphql`
|
||||||
* `LOG_LEVEL` - Logging level. Default: `info`
|
* `GRAPHQL_CACHE_ENABLED` - Should the query cache be enabled? Default: `false`
|
||||||
|
* `GRAPHQL_CACHE_TTL` - Cache TTL in seconds for SELECT type of queries. Default: `5`
|
||||||
|
* `GRAPHQL_OUTPUT` - Output format. Default: `string`, available: `byte`, `string`, `mapstring`
|
||||||
|
* `LOG_LEVEL` - Logging level. Default: `info` available: `debug`, `info`, `warn`, `error`
|
||||||
|
* `GRAPHQL_RETRIES_ENABLE` - Should retries be enabled? Default: `false`
|
||||||
|
* `GRAPHQL_RETRIES_NUMBER` - Number of retries: Default: `3`
|
||||||
|
* `GRAPHQL_RETRIES_DELAY` - Delay in retries in milliseconds. Default: `250`
|
||||||
|
* `GRAPHQL_RETRIES_PATTERNS` - Comma-separated error patterns that trigger retries. Default: `postgres,connection,timeout,transaction,could not,temporarily unavailable,deadlock`
|
||||||
|
* `GRAPHQL_POOL_WARMUP_ENABLED` - Enable intelligent connection pool warmup. Default: `false`
|
||||||
|
* `GRAPHQL_POOL_SIZE` - Number of connections to pre-create and maintain. Default: `5`
|
||||||
|
* `GRAPHQL_POOL_WARMUP_QUERY` - Query used for warming up connections. Default: `query{__typename}`
|
||||||
|
* `GRAPHQL_POOL_HEALTH_INTERVAL` - Interval in seconds for pool health checks. Default: `30`
|
||||||
|
|
||||||
|
### Modifiers on the fly
|
||||||
|
|
||||||
|
* `gql.SetEndpoint('your-endpoint-url')` - modifies endpoint, without the need to set the environment variable
|
||||||
|
* `gql.SetOutput('byte')` - modifies output format, without the need to set the environment variable
|
||||||
|
|
||||||
|
### Retries
|
||||||
|
|
||||||
|
The library supports automatic retries for both HTTP-level errors and GraphQL errors that match configurable patterns.
|
||||||
|
|
||||||
|
**Retry behavior:**
|
||||||
|
- **HTTP errors** (status codes outside 200-299): Always retried if retries are enabled
|
||||||
|
- **GraphQL errors**: Only retried if the error message contains one of the configured patterns
|
||||||
|
- **Non-matching errors**: Fail immediately without retrying (e.g., validation errors, field not found)
|
||||||
|
|
||||||
|
**Configuring retryable patterns:**
|
||||||
|
|
||||||
|
By default, errors containing these patterns will trigger retries:
|
||||||
|
- `postgres` - Database connection/transaction errors
|
||||||
|
- `connection` - Network connection issues
|
||||||
|
- `timeout` - Request timeout errors
|
||||||
|
- `transaction` - Database transaction conflicts
|
||||||
|
- `could not` - Generic transient failures
|
||||||
|
- `temporarily unavailable` - Service unavailability
|
||||||
|
- `deadlock` - Database deadlocks
|
||||||
|
|
||||||
|
You can customize these patterns using the `GRAPHQL_RETRIES_PATTERNS` environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GRAPHQL_RETRIES_PATTERNS="postgres,connection,timeout,custom-error-pattern"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:** To handle flaky Hasura database connections, enable retries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GRAPHQL_RETRIES_ENABLE=true
|
||||||
|
export GRAPHQL_RETRIES_NUMBER=3
|
||||||
|
export GRAPHQL_RETRIES_DELAY=250
|
||||||
|
```
|
||||||
|
|
||||||
|
This will retry GraphQL queries up to 3 times when postgres transaction errors occur, with exponential backoff starting at 250ms.
|
||||||
|
|
||||||
|
### Intelligent Connection Pooling
|
||||||
|
|
||||||
|
The library supports intelligent connection pooling with pre-warmed connections and automatic health monitoring. This significantly improves performance during traffic spikes by keeping hot standby connections ready.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- **Pre-warming**: Connections are created and warmed up during initialization
|
||||||
|
- **Health monitoring**: Background process periodically checks connection health
|
||||||
|
- **Auto-refresh**: Automatically replaces stale or broken connections
|
||||||
|
- **Hot standbys**: Ready connections eliminate cold-start latency
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
Enable connection pool warmup for production environments with variable traffic:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GRAPHQL_POOL_WARMUP_ENABLED=true
|
||||||
|
export GRAPHQL_POOL_SIZE=5 # Number of hot standby connections
|
||||||
|
export GRAPHQL_POOL_HEALTH_INTERVAL=30 # Health check every 30 seconds
|
||||||
|
export GRAPHQL_POOL_WARMUP_QUERY="query{__typename}" # Lightweight health check query
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ⚡ **Instant response** - Connections already established during traffic spikes
|
||||||
|
- 🔄 **Auto-healing** - Detects and replaces broken connections automatically
|
||||||
|
- 📊 **Predictable performance** - Eliminates connection establishment latency
|
||||||
|
- 🛡️ **Resilience** - Maintains pool health even during network instability
|
||||||
|
|
||||||
|
**Recommended settings by use case:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# High-traffic API (e.g., production web app)
|
||||||
|
GRAPHQL_POOL_WARMUP_ENABLED=true
|
||||||
|
GRAPHQL_POOL_SIZE=10
|
||||||
|
GRAPHQL_POOL_HEALTH_INTERVAL=15
|
||||||
|
|
||||||
|
# Medium-traffic service (e.g., background jobs)
|
||||||
|
GRAPHQL_POOL_WARMUP_ENABLED=true
|
||||||
|
GRAPHQL_POOL_SIZE=5
|
||||||
|
GRAPHQL_POOL_HEALTH_INTERVAL=30
|
||||||
|
|
||||||
|
# Low-traffic/development
|
||||||
|
GRAPHQL_POOL_WARMUP_ENABLED=false # Default: on-demand connections
|
||||||
|
```
|
||||||
|
|
||||||
|
**Graceful shutdown:**
|
||||||
|
|
||||||
|
When shutting down your application, stop the pool monitor gracefully:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func main() {
|
||||||
|
gql := graphql.NewConnection()
|
||||||
|
defer gql.StopPoolMonitor() // Ensure clean shutdown
|
||||||
|
|
||||||
|
// Your application logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache
|
||||||
|
|
||||||
|
You have two options to enable the cache:
|
||||||
|
|
||||||
|
* Use `GRAPHQL_CACHE_ENABLED` environment variable which will enable the cache globally. It may be desired if you want to use the cache for all queries.
|
||||||
|
* Add `gqlcache: true` header for your query which will enable the cache for this query only with `GRAPHQL_CACHE_TTL` TTL.
|
||||||
|
* You can check the list of supported per-query modifiers below
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// following values passed as headers will modify behaviour of the query
|
||||||
|
// and disregard settings provided via environment variables
|
||||||
|
headers := map[string]interface{}{
|
||||||
|
...
|
||||||
|
"gqlcache": true, // sets the cache as on for this query only
|
||||||
|
"gqlretries": false, // disables retries for this query only
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Example reader code
|
### Example reader code
|
||||||
|
|
||||||
@@ -37,35 +172,42 @@ Therefore, I present you the simple client to which you can copy & paste your gr
|
|||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
fmt
|
fmt
|
||||||
gql "github.com/lukaszraczylo/simple-gql-client"
|
graphql "github.com/lukaszraczylo/go-simple-graphql"
|
||||||
)
|
)
|
||||||
|
|
||||||
headers := map[string]interface{}{
|
func main() {
|
||||||
"x-hasura-user-id": 37,
|
headers := map[string]interface{}{
|
||||||
"x-hasura-user-uuid": "bde3262e-b42e-4151-ac10-d43f0bef44a5",
|
"x-hasura-user-id": 37,
|
||||||
}
|
"x-hasura-user-uuid": "bde1962e-b42e-1212-ac10-d43fa27f44a5",
|
||||||
|
|
||||||
variables := map[string]interface{}{
|
|
||||||
"fileHash": "123deadc0w321",
|
|
||||||
}
|
|
||||||
var query = `query searchFileKnown($fileHash: String) {
|
|
||||||
tbl_file_scans(where: {file_hash: {_eq: $fileHash}}) {
|
|
||||||
porn
|
|
||||||
racy
|
|
||||||
violence
|
|
||||||
virus
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
graphql := NewConnection()
|
variables := map[string]interface{}{
|
||||||
result, err := graphql.Query(query, variables, nil)
|
"fileHash": "123deadc0w321",
|
||||||
if err != nil {
|
}
|
||||||
fmt.Println("Query error", err)
|
|
||||||
return
|
query := `query searchFileKnown($fileHash: String) {
|
||||||
|
tbl_file_scans(where: {file_hash: {_eq: $fileHash}}) {
|
||||||
|
racy
|
||||||
|
violence
|
||||||
|
virus
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
gql := graphql.NewConnection()
|
||||||
|
result, err := gql.Query(query, variables, headers)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Query error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(result)
|
||||||
}
|
}
|
||||||
fmt.Println(result)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
* Connection handler ( `gql := graphql.NewConnection()` ) should be created once and reused in the application especially if you run dozens of queries per second. It will allow you also to use cache and http2 to its full potential.
|
||||||
|
|
||||||
|
|
||||||
**Result**
|
**Result**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -74,10 +216,12 @@ fmt.Println(result)
|
|||||||
|
|
||||||
## Working with results
|
## Working with results
|
||||||
|
|
||||||
I'm using an amazing library [tidwall/gjson](https://github.com/tidwall/gjson) to parse the results and extract information required in further steps and I strongly recommend this approach as the easiest and close to painless.
|
Currently attempting to switch to the fork of the [`ask` library](https://github.com/lukaszraczylo/ask)
|
||||||
|
|
||||||
|
Before, I used an amazing library [tidwall/gjson](https://github.com/tidwall/gjson) to parse the results and extract the information required in further steps and I strongly recommend this approach as the easiest and close to painless, for example:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
result = gjson.Get(result, "tbl_user_group_admins.0.is_admin").Bool()
|
result := gjson.Get(result, "tbl_user_group_admins.0.is_admin").Bool()
|
||||||
if result {
|
if result {
|
||||||
fmt.Println("User is an admin")
|
fmt.Println("User is an admin")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Benchmark tiered buffer pools vs single pool
|
||||||
|
func BenchmarkBufferPools(b *testing.B) {
|
||||||
|
// Test data of different sizes
|
||||||
|
smallData := make([]byte, 512)
|
||||||
|
mediumData := make([]byte, 2048)
|
||||||
|
largeData := make([]byte, 8192)
|
||||||
|
|
||||||
|
for i := range smallData {
|
||||||
|
smallData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
for i := range mediumData {
|
||||||
|
mediumData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
for i := range largeData {
|
||||||
|
largeData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Run("SmallBuffer_GetPut", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
buf := getBuffer(len(smallData))
|
||||||
|
buf.Write(smallData)
|
||||||
|
putBuffer(buf)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("MediumBuffer_GetPut", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
buf := getBuffer(len(mediumData))
|
||||||
|
buf.Write(mediumData)
|
||||||
|
putBuffer(buf)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("LargeBuffer_GetPut", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
buf := getBuffer(len(largeData))
|
||||||
|
buf.Write(largeData)
|
||||||
|
putBuffer(buf)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Compare with old single pool approach
|
||||||
|
b.Run("SinglePool_Small", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
buf := bufferPool.Get().(*bytes.Buffer)
|
||||||
|
buf.Reset()
|
||||||
|
buf.Write(smallData)
|
||||||
|
bufferPool.Put(buf)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("SinglePool_Medium", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
buf := bufferPool.Get().(*bytes.Buffer)
|
||||||
|
buf.Reset()
|
||||||
|
buf.Write(mediumData)
|
||||||
|
bufferPool.Put(buf)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("SinglePool_Large", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
buf := bufferPool.Get().(*bytes.Buffer)
|
||||||
|
buf.Reset()
|
||||||
|
buf.Write(largeData)
|
||||||
|
bufferPool.Put(buf)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark buffer allocation patterns
|
||||||
|
func BenchmarkBufferAllocation(b *testing.B) {
|
||||||
|
b.Run("TieredPools_Mixed", func(b *testing.B) {
|
||||||
|
sizes := []int{256, 512, 1024, 2048, 4096, 8192, 16384}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
size := sizes[i%len(sizes)]
|
||||||
|
buf := getBuffer(size)
|
||||||
|
data := make([]byte, size)
|
||||||
|
buf.Write(data)
|
||||||
|
putBuffer(buf)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("DirectAllocation_Mixed", func(b *testing.B) {
|
||||||
|
sizes := []int{256, 512, 1024, 2048, 4096, 8192, 16384}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
size := sizes[i%len(sizes)]
|
||||||
|
buf := bytes.NewBuffer(make([]byte, 0, size))
|
||||||
|
data := make([]byte, size)
|
||||||
|
buf.Write(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test buffer pool correctness
|
||||||
|
func TestBufferPoolCorrectness(t *testing.T) {
|
||||||
|
testData := []byte("Hello, World! This is test data for buffer pool testing.")
|
||||||
|
|
||||||
|
// Test small buffer
|
||||||
|
buf := getBuffer(len(testData))
|
||||||
|
buf.Write(testData)
|
||||||
|
if buf.Len() != len(testData) {
|
||||||
|
t.Errorf("Buffer length mismatch: expected %d, got %d", len(testData), buf.Len())
|
||||||
|
}
|
||||||
|
if !bytes.Equal(buf.Bytes(), testData) {
|
||||||
|
t.Error("Buffer content mismatch")
|
||||||
|
}
|
||||||
|
putBuffer(buf)
|
||||||
|
|
||||||
|
// Test that buffer is properly reset when returned to pool
|
||||||
|
buf2 := getBuffer(10)
|
||||||
|
if buf2.Len() != 0 {
|
||||||
|
t.Error("Buffer not properly reset")
|
||||||
|
}
|
||||||
|
putBuffer(buf2)
|
||||||
|
|
||||||
|
// Test large buffer
|
||||||
|
largeData := make([]byte, 10000)
|
||||||
|
for i := range largeData {
|
||||||
|
largeData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
largeBuf := getBuffer(len(largeData))
|
||||||
|
largeBuf.Write(largeData)
|
||||||
|
if largeBuf.Len() != len(largeData) {
|
||||||
|
t.Errorf("Large buffer length mismatch: expected %d, got %d", len(largeData), largeBuf.Len())
|
||||||
|
}
|
||||||
|
putBuffer(largeBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test buffer pool size selection
|
||||||
|
func TestBufferPoolSizeSelection(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
size int
|
||||||
|
expectedPool string
|
||||||
|
}{
|
||||||
|
{100, "small"},
|
||||||
|
{1024, "small"},
|
||||||
|
{1025, "medium"},
|
||||||
|
{4096, "medium"},
|
||||||
|
{4097, "large"},
|
||||||
|
{16384, "large"},
|
||||||
|
{20000, "large"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
buf := getBuffer(tc.size)
|
||||||
|
capacity := buf.Cap()
|
||||||
|
|
||||||
|
var poolType string
|
||||||
|
switch {
|
||||||
|
case capacity <= 1024:
|
||||||
|
poolType = "small"
|
||||||
|
case capacity <= 4096:
|
||||||
|
poolType = "medium"
|
||||||
|
default:
|
||||||
|
poolType = "large"
|
||||||
|
}
|
||||||
|
|
||||||
|
if poolType != tc.expectedPool {
|
||||||
|
t.Errorf("Size %d: expected %s pool, got %s pool (capacity: %d)",
|
||||||
|
tc.size, tc.expectedPool, poolType, capacity)
|
||||||
|
}
|
||||||
|
putBuffer(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+275
@@ -0,0 +1,275 @@
|
|||||||
|
package libpack_cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"hash/fnv"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CacheEntry struct {
|
||||||
|
ExpiresAt time.Time
|
||||||
|
Value []byte
|
||||||
|
IsCompressed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const shardCount = 256 // Must be power of 2
|
||||||
|
|
||||||
|
type shard struct {
|
||||||
|
entries map[string]CacheEntry
|
||||||
|
sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache struct {
|
||||||
|
compressPool sync.Pool
|
||||||
|
decompressPool sync.Pool
|
||||||
|
shards [shardCount]*shard
|
||||||
|
globalTTL time.Duration
|
||||||
|
cleanupChan chan struct{}
|
||||||
|
stopChan chan struct{}
|
||||||
|
stopped bool
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// getShard returns the appropriate shard for a given key
|
||||||
|
func (c *Cache) getShard(key string) *shard {
|
||||||
|
hash := fnv.New32a()
|
||||||
|
hash.Write([]byte(key))
|
||||||
|
return c.shards[hash.Sum32()%shardCount]
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(globalTTL time.Duration) *Cache {
|
||||||
|
cache := &Cache{
|
||||||
|
globalTTL: globalTTL,
|
||||||
|
cleanupChan: make(chan struct{}, 1),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
compressPool: sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
w := gzip.NewWriter(nil)
|
||||||
|
return w
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decompressPool: sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
r, _ := gzip.NewReader(bytes.NewReader([]byte{}))
|
||||||
|
return r
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize shards
|
||||||
|
for i := 0; i < shardCount; i++ {
|
||||||
|
cache.shards[i] = &shard{
|
||||||
|
entries: make(map[string]CacheEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go cache.lazyCleanupWorker()
|
||||||
|
go cache.periodicCleanupRoutine(globalTTL)
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) lazyCleanupWorker() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.cleanupChan:
|
||||||
|
c.mu.RLock()
|
||||||
|
if c.stopped {
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.RUnlock()
|
||||||
|
c.CleanExpiredEntries()
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) periodicCleanupRoutine(globalTTL time.Duration) {
|
||||||
|
// Use a shorter cleanup interval for better responsiveness, but not less than 1 second
|
||||||
|
cleanupInterval := globalTTL / 4
|
||||||
|
if cleanupInterval < time.Second {
|
||||||
|
cleanupInterval = time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(cleanupInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
c.mu.RLock()
|
||||||
|
if c.stopped {
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.RUnlock()
|
||||||
|
c.CleanExpiredEntries()
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) triggerLazyCleanup() {
|
||||||
|
c.mu.RLock()
|
||||||
|
if c.stopped {
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c.cleanupChan <- struct{}{}:
|
||||||
|
// Cleanup triggered
|
||||||
|
default:
|
||||||
|
// Cleanup already pending, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Stop() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.stopped {
|
||||||
|
return // Already stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
c.stopped = true
|
||||||
|
close(c.stopChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
|
||||||
|
shard := c.getShard(key)
|
||||||
|
shard.Lock()
|
||||||
|
defer shard.Unlock()
|
||||||
|
|
||||||
|
const compressionThreshold = 1024 // 1KB threshold
|
||||||
|
var finalValue []byte
|
||||||
|
var isCompressed bool
|
||||||
|
|
||||||
|
if len(value) >= compressionThreshold {
|
||||||
|
compressedValue, err := c.compress(value)
|
||||||
|
if err != nil {
|
||||||
|
// If compression fails, store uncompressed
|
||||||
|
finalValue = value
|
||||||
|
isCompressed = false
|
||||||
|
} else {
|
||||||
|
// Only use compression if it actually reduces size
|
||||||
|
if len(compressedValue) < len(value) {
|
||||||
|
finalValue = compressedValue
|
||||||
|
isCompressed = true
|
||||||
|
} else {
|
||||||
|
finalValue = value
|
||||||
|
isCompressed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalValue = value
|
||||||
|
isCompressed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
shard.entries[key] = CacheEntry{
|
||||||
|
Value: finalValue,
|
||||||
|
ExpiresAt: time.Now().Add(ttl),
|
||||||
|
IsCompressed: isCompressed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Get(key string) ([]byte, bool) {
|
||||||
|
shard := c.getShard(key)
|
||||||
|
shard.RLock()
|
||||||
|
entry, ok := shard.entries[key]
|
||||||
|
if !ok {
|
||||||
|
shard.RUnlock()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.ExpiresAt.Before(time.Now()) {
|
||||||
|
shard.RUnlock()
|
||||||
|
// Trigger lazy cleanup instead of immediate deletion
|
||||||
|
c.triggerLazyCleanup()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
shard.RUnlock()
|
||||||
|
|
||||||
|
var value []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if entry.IsCompressed {
|
||||||
|
value, err = c.decompress(entry.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value = entry.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Delete(key string) {
|
||||||
|
shard := c.getShard(key)
|
||||||
|
shard.Lock()
|
||||||
|
delete(shard.entries, key)
|
||||||
|
shard.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) CleanExpiredEntries() {
|
||||||
|
now := time.Now()
|
||||||
|
for _, shard := range c.shards {
|
||||||
|
shard.Lock()
|
||||||
|
for key, entry := range shard.entries {
|
||||||
|
if entry.ExpiresAt.Before(now) {
|
||||||
|
delete(shard.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shard.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) compress(data []byte) ([]byte, error) {
|
||||||
|
w := c.compressPool.Get().(*gzip.Writer)
|
||||||
|
defer c.compressPool.Put(w)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w.Reset(&buf)
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) decompress(data []byte) ([]byte, error) {
|
||||||
|
r, ok := c.decompressPool.Get().(*gzip.Reader)
|
||||||
|
if !ok || r == nil {
|
||||||
|
// If r is nil or type assertion fails, create a new gzip.Reader
|
||||||
|
var err error
|
||||||
|
r, err = gzip.NewReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err // Handle the error if gzip.NewReader fails
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset the existing reader with new data
|
||||||
|
if err := r.Reset(bytes.NewReader(data)); err != nil {
|
||||||
|
return nil, err // Handle the error if Reset fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
// Ensure the reader is returned to the pool
|
||||||
|
defer c.decompressPool.Put(r)
|
||||||
|
|
||||||
|
// Read all the data from the reader
|
||||||
|
decompressedData, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err // Handle the error if reading fails
|
||||||
|
}
|
||||||
|
return decompressedData, nil
|
||||||
|
}
|
||||||
Vendored
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package libpack_cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assume that New function initializes the cache and it is defined somewhere in the libpack_cache package.
|
||||||
|
|
||||||
|
func BenchmarkCacheSet(b *testing.B) {
|
||||||
|
cache := New(5 * time.Second) // Use shorter TTL for tests
|
||||||
|
defer cache.Stop()
|
||||||
|
key := "benchmark-key"
|
||||||
|
value := []byte("benchmark-value")
|
||||||
|
|
||||||
|
b.ResetTimer() // Reset the timer to exclude the setup time from the benchmark
|
||||||
|
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
cache.Set(key, value, 2*time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkCacheGet(b *testing.B) {
|
||||||
|
cache := New(5 * time.Second) // Use shorter TTL for tests
|
||||||
|
defer cache.Stop()
|
||||||
|
key := "benchmark-key"
|
||||||
|
value := []byte("benchmark-value")
|
||||||
|
cache.Set(key, value, 2*time.Second) // Pre-set a value to retrieve
|
||||||
|
|
||||||
|
b.ResetTimer() // Start timing
|
||||||
|
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = cache.Get(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkCacheExpire(b *testing.B) {
|
||||||
|
cache := New(5 * time.Second) // Use shorter TTL for tests and reuse cache
|
||||||
|
defer cache.Stop()
|
||||||
|
value := []byte("benchmark-value")
|
||||||
|
ttl := 5 * time.Millisecond // Setting a short TTL for quick expiration
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
key := "benchmark-expire-key-" + string(rune(i)) // Use unique keys
|
||||||
|
cache.Set(key, value, ttl)
|
||||||
|
time.Sleep(ttl + time.Millisecond) // Wait for the key to expire with buffer
|
||||||
|
_, _ = cache.Get(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+319
@@ -0,0 +1,319 @@
|
|||||||
|
package libpack_cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CacheTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) SetupTest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachingTestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(CacheTestSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_New() {
|
||||||
|
suite.T().Run("should return a new cache", func(t *testing.T) {
|
||||||
|
cache := New(2 * time.Second)
|
||||||
|
defer cache.Stop()
|
||||||
|
suite.NotNil(cache)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_CacheUse() {
|
||||||
|
cache := New(5 * time.Second)
|
||||||
|
defer cache.Stop()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cache_value string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "test1",
|
||||||
|
cache_value: "test1-123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test2",
|
||||||
|
cache_value: "test2-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
|
cache.Set(tt.name, []byte(tt.name), 2*time.Second)
|
||||||
|
c, ok := cache.Get(tt.name)
|
||||||
|
suite.Equal(true, ok)
|
||||||
|
suite.Equal(tt.name, string(c))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_CacheDelete() {
|
||||||
|
cache := New(5 * time.Second)
|
||||||
|
defer cache.Stop()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cache_value string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "test1",
|
||||||
|
cache_value: "test1-123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test2",
|
||||||
|
cache_value: "test2-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
|
cache.Set(tt.name, []byte(tt.name), 2*time.Second)
|
||||||
|
c, ok := cache.Get(tt.name)
|
||||||
|
suite.Equal(true, ok)
|
||||||
|
suite.Equal(tt.name, string(c))
|
||||||
|
cache.Delete(tt.name)
|
||||||
|
c, ok = cache.Get(tt.name)
|
||||||
|
suite.Equal(false, ok)
|
||||||
|
suite.Equal("", string(c))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_CacheExpire() {
|
||||||
|
cache := New(5 * time.Second)
|
||||||
|
defer cache.Stop()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cache_value string
|
||||||
|
ttl time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "test1",
|
||||||
|
cache_value: "test1-123",
|
||||||
|
ttl: 100 * time.Millisecond,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test2",
|
||||||
|
cache_value: "test2-123",
|
||||||
|
ttl: 200 * time.Millisecond,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
|
cache.Set(tt.name, []byte(tt.name), tt.ttl)
|
||||||
|
c, ok := cache.Get(tt.name)
|
||||||
|
suite.Equal(true, ok)
|
||||||
|
suite.Equal(tt.name, string(c))
|
||||||
|
time.Sleep(tt.ttl + 50*time.Millisecond) // Add small buffer
|
||||||
|
c, ok = cache.Get(tt.name)
|
||||||
|
suite.Equal(false, ok)
|
||||||
|
suite.Equal("", string(c))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_CacheCompression() {
|
||||||
|
cache := New(5 * time.Second)
|
||||||
|
defer cache.Stop()
|
||||||
|
|
||||||
|
suite.T().Run("should compress large data", func(t *testing.T) {
|
||||||
|
// Create data larger than compression threshold (1024 bytes)
|
||||||
|
largeData := make([]byte, 2048)
|
||||||
|
for i := range largeData {
|
||||||
|
largeData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.Set("large_key", largeData, 2*time.Second)
|
||||||
|
retrieved, ok := cache.Get("large_key")
|
||||||
|
suite.True(ok)
|
||||||
|
suite.Equal(largeData, retrieved)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle compression errors gracefully", func(t *testing.T) {
|
||||||
|
// Test with data that might cause compression issues
|
||||||
|
largeData := make([]byte, 1500)
|
||||||
|
for i := range largeData {
|
||||||
|
largeData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.Set("compression_test", largeData, 2*time.Second)
|
||||||
|
retrieved, ok := cache.Get("compression_test")
|
||||||
|
suite.True(ok)
|
||||||
|
suite.Equal(largeData, retrieved)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should not compress small data", func(t *testing.T) {
|
||||||
|
smallData := []byte("small data")
|
||||||
|
cache.Set("small_key", smallData, 2*time.Second)
|
||||||
|
retrieved, ok := cache.Get("small_key")
|
||||||
|
suite.True(ok)
|
||||||
|
suite.Equal(smallData, retrieved)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_CacheCompressionEdgeCases() {
|
||||||
|
cache := New(5 * time.Second)
|
||||||
|
defer cache.Stop()
|
||||||
|
|
||||||
|
suite.T().Run("should handle highly compressible data", func(t *testing.T) {
|
||||||
|
// Create highly compressible data (repeated pattern)
|
||||||
|
compressibleData := make([]byte, 2048)
|
||||||
|
for i := range compressibleData {
|
||||||
|
compressibleData[i] = 'A' // All same character
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.Set("compressible", compressibleData, 2*time.Second)
|
||||||
|
retrieved, ok := cache.Get("compressible")
|
||||||
|
suite.True(ok)
|
||||||
|
suite.Equal(compressibleData, retrieved)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle incompressible data", func(t *testing.T) {
|
||||||
|
// Create random-like data that doesn't compress well
|
||||||
|
incompressibleData := make([]byte, 1500)
|
||||||
|
for i := range incompressibleData {
|
||||||
|
incompressibleData[i] = byte((i*7 + i*i) % 256) // Pseudo-random pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.Set("incompressible", incompressibleData, 2*time.Second)
|
||||||
|
retrieved, ok := cache.Get("incompressible")
|
||||||
|
suite.True(ok)
|
||||||
|
suite.Equal(incompressibleData, retrieved)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_CacheCleanup() {
|
||||||
|
cache := New(100 * time.Millisecond) // Short TTL for faster testing
|
||||||
|
defer cache.Stop()
|
||||||
|
|
||||||
|
suite.T().Run("should clean expired entries", func(t *testing.T) {
|
||||||
|
cache.Set("expire1", []byte("value1"), 50*time.Millisecond)
|
||||||
|
cache.Set("expire2", []byte("value2"), 50*time.Millisecond)
|
||||||
|
cache.Set("persist", []byte("persist"), 5*time.Second)
|
||||||
|
|
||||||
|
// Verify entries exist
|
||||||
|
_, ok := cache.Get("expire1")
|
||||||
|
suite.True(ok)
|
||||||
|
_, ok = cache.Get("expire2")
|
||||||
|
suite.True(ok)
|
||||||
|
_, ok = cache.Get("persist")
|
||||||
|
suite.True(ok)
|
||||||
|
|
||||||
|
// Wait for expiration
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Trigger cleanup
|
||||||
|
cache.CleanExpiredEntries()
|
||||||
|
|
||||||
|
// Check expired entries are gone
|
||||||
|
_, ok = cache.Get("expire1")
|
||||||
|
suite.False(ok)
|
||||||
|
_, ok = cache.Get("expire2")
|
||||||
|
suite.False(ok)
|
||||||
|
|
||||||
|
// Check persistent entry still exists
|
||||||
|
_, ok = cache.Get("persist")
|
||||||
|
suite.True(ok)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_CacheLazyCleanup() {
|
||||||
|
cache := New(5 * time.Second)
|
||||||
|
defer cache.Stop()
|
||||||
|
|
||||||
|
suite.T().Run("should trigger lazy cleanup on expired access", func(t *testing.T) {
|
||||||
|
cache.Set("lazy_expire", []byte("value"), 50*time.Millisecond)
|
||||||
|
|
||||||
|
// Verify entry exists
|
||||||
|
_, ok := cache.Get("lazy_expire")
|
||||||
|
suite.True(ok)
|
||||||
|
|
||||||
|
// Wait for expiration
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Access expired entry should trigger lazy cleanup
|
||||||
|
_, ok = cache.Get("lazy_expire")
|
||||||
|
suite.False(ok)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_CacheStop() {
|
||||||
|
suite.T().Run("should stop cache gracefully", func(t *testing.T) {
|
||||||
|
cache := New(5 * time.Second)
|
||||||
|
|
||||||
|
// Add some data
|
||||||
|
cache.Set("test", []byte("value"), 2*time.Second)
|
||||||
|
_, ok := cache.Get("test")
|
||||||
|
suite.True(ok)
|
||||||
|
|
||||||
|
// Stop cache
|
||||||
|
cache.Stop()
|
||||||
|
|
||||||
|
// Should be able to stop multiple times without panic
|
||||||
|
cache.Stop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_CacheSharding() {
|
||||||
|
cache := New(5 * time.Second)
|
||||||
|
defer cache.Stop()
|
||||||
|
|
||||||
|
suite.T().Run("should distribute keys across shards", func(t *testing.T) {
|
||||||
|
// Test multiple keys to ensure sharding works
|
||||||
|
keys := []string{"key1", "key2", "key3", "key4", "key5"}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
cache.Set(key, []byte("value_"+key), 2*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
value, ok := cache.Get(key)
|
||||||
|
suite.True(ok)
|
||||||
|
suite.Equal("value_"+key, string(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_CachePeriodicCleanup() {
|
||||||
|
suite.T().Run("should handle short TTL for periodic cleanup", func(t *testing.T) {
|
||||||
|
// Test with very short TTL to trigger the minimum cleanup interval logic
|
||||||
|
cache := New(500 * time.Millisecond)
|
||||||
|
defer cache.Stop()
|
||||||
|
|
||||||
|
cache.Set("short_ttl", []byte("value"), 100*time.Millisecond)
|
||||||
|
|
||||||
|
// Wait for periodic cleanup to potentially run
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
_, ok := cache.Get("short_ttl")
|
||||||
|
suite.False(ok)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CacheTestSuite) Test_CacheDecompressionErrors() {
|
||||||
|
cache := New(5 * time.Second)
|
||||||
|
defer cache.Stop()
|
||||||
|
|
||||||
|
suite.T().Run("should handle decompression pool edge cases", func(t *testing.T) {
|
||||||
|
// This test ensures the decompression pool handles nil readers correctly
|
||||||
|
largeData := make([]byte, 2048)
|
||||||
|
for i := range largeData {
|
||||||
|
largeData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set and get multiple times to exercise the pool
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
key := "pool_test_" + string(rune('0'+i))
|
||||||
|
cache.Set(key, largeData, 2*time.Second)
|
||||||
|
retrieved, ok := cache.Get(key)
|
||||||
|
suite.True(ok)
|
||||||
|
suite.Equal(largeData, retrieved)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cache "github.com/lukaszraczylo/go-simple-graphql/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Benchmark cache compression with different data sizes
|
||||||
|
func BenchmarkCacheCompression(b *testing.B) {
|
||||||
|
c := cache.New(5 * time.Second) // Use shorter TTL for tests
|
||||||
|
defer c.Stop()
|
||||||
|
|
||||||
|
// Small data (under 1KB threshold)
|
||||||
|
smallData := make([]byte, 512)
|
||||||
|
for i := range smallData {
|
||||||
|
smallData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medium data (over 1KB threshold)
|
||||||
|
mediumData := make([]byte, 2048)
|
||||||
|
for i := range mediumData {
|
||||||
|
mediumData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large data (much larger than threshold)
|
||||||
|
largeData := make([]byte, 8192)
|
||||||
|
for i := range largeData {
|
||||||
|
largeData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highly compressible data
|
||||||
|
compressibleData := make([]byte, 4096)
|
||||||
|
for i := range compressibleData {
|
||||||
|
compressibleData[i] = 'A' // Repeating character compresses well
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Run("SmallData_Set", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
c.Set("small", smallData, time.Minute)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("SmallData_Get", func(b *testing.B) {
|
||||||
|
c.Set("small", smallData, time.Minute)
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = c.Get("small")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("MediumData_Set", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
c.Set("medium", mediumData, time.Minute)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("MediumData_Get", func(b *testing.B) {
|
||||||
|
c.Set("medium", mediumData, time.Minute)
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = c.Get("medium")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("LargeData_Set", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
c.Set("large", largeData, time.Minute)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("LargeData_Get", func(b *testing.B) {
|
||||||
|
c.Set("large", largeData, time.Minute)
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = c.Get("large")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("CompressibleData_Set", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
c.Set("compressible", compressibleData, time.Minute)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("CompressibleData_Get", func(b *testing.B) {
|
||||||
|
c.Set("compressible", compressibleData, time.Minute)
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = c.Get("compressible")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark cache memory usage with compression
|
||||||
|
func BenchmarkCacheMemoryUsage(b *testing.B) {
|
||||||
|
c := cache.New(5 * time.Second) // Use shorter TTL for tests
|
||||||
|
defer c.Stop()
|
||||||
|
|
||||||
|
// Test data that compresses well
|
||||||
|
testData := make([]byte, 4096)
|
||||||
|
for i := range testData {
|
||||||
|
testData[i] = byte(i%10 + '0') // Repeating pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Run("WithCompression", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
key := "key" + string(rune(i%1000))
|
||||||
|
c.Set(key, testData, time.Minute)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test compression effectiveness
|
||||||
|
func TestCompressionEffectiveness(t *testing.T) {
|
||||||
|
c := cache.New(5 * time.Second) // Use shorter TTL for tests
|
||||||
|
defer c.Stop()
|
||||||
|
|
||||||
|
// Highly compressible data
|
||||||
|
compressibleData := make([]byte, 4096)
|
||||||
|
for i := range compressibleData {
|
||||||
|
compressibleData[i] = 'A'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random data (less compressible)
|
||||||
|
randomData := make([]byte, 4096)
|
||||||
|
for i := range randomData {
|
||||||
|
randomData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small data (under threshold)
|
||||||
|
smallData := make([]byte, 512)
|
||||||
|
for i := range smallData {
|
||||||
|
smallData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test compressible data
|
||||||
|
c.Set("compressible", compressibleData, time.Minute)
|
||||||
|
retrieved, ok := c.Get("compressible")
|
||||||
|
if !ok {
|
||||||
|
t.Error("Failed to retrieve compressible data")
|
||||||
|
}
|
||||||
|
if len(retrieved) != len(compressibleData) {
|
||||||
|
t.Errorf("Data length mismatch: expected %d, got %d", len(compressibleData), len(retrieved))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test random data
|
||||||
|
c.Set("random", randomData, time.Minute)
|
||||||
|
retrieved, ok = c.Get("random")
|
||||||
|
if !ok {
|
||||||
|
t.Error("Failed to retrieve random data")
|
||||||
|
}
|
||||||
|
if len(retrieved) != len(randomData) {
|
||||||
|
t.Errorf("Data length mismatch: expected %d, got %d", len(randomData), len(retrieved))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test small data (should not be compressed)
|
||||||
|
c.Set("small", smallData, time.Minute)
|
||||||
|
retrieved, ok = c.Get("small")
|
||||||
|
if !ok {
|
||||||
|
t.Error("Failed to retrieve small data")
|
||||||
|
}
|
||||||
|
if len(retrieved) != len(smallData) {
|
||||||
|
t.Errorf("Data length mismatch: expected %d, got %d", len(smallData), len(retrieved))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/avast/retry-go/v4"
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
libpack_logger "github.com/lukaszraczylo/go-simple-graphql/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// min returns the smaller of two integers
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// max returns the larger of two integers
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRetryableError checks if GraphQL errors contain patterns that indicate a transient failure
|
||||||
|
// that should be retried (e.g., database connection issues, timeouts, transaction conflicts)
|
||||||
|
func isRetryableError(errors []struct {
|
||||||
|
Message interface{} `json:"message"`
|
||||||
|
}, patterns []string) bool {
|
||||||
|
if len(errors) == 0 || len(patterns) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, err := range errors {
|
||||||
|
// Try to extract error message as string
|
||||||
|
var msgStr string
|
||||||
|
switch msg := err.Message.(type) {
|
||||||
|
case string:
|
||||||
|
msgStr = msg
|
||||||
|
case map[string]interface{}:
|
||||||
|
// Handle nested error structures (e.g., {"message": "...", "extensions": {...}})
|
||||||
|
if nestedMsg, ok := msg["message"].(string); ok {
|
||||||
|
msgStr = nestedMsg
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// If we can't convert to string, try fmt.Sprint as fallback
|
||||||
|
msgStr = fmt.Sprint(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgStr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if error message contains any of the configured retry patterns (case-insensitive)
|
||||||
|
msgLower := strings.ToLower(msgStr)
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if strings.Contains(msgLower, strings.ToLower(pattern)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: defaultClient removed - all clients should be created via NewConnection()
|
||||||
|
// which properly configures HTTP/2 transport with correct settings
|
||||||
|
|
||||||
|
func (qe *QueryExecutor) executeQuery() ([]byte, error) {
|
||||||
|
// Reuse buffer from pool to avoid allocations
|
||||||
|
buf := bufferPool.Get().(*bytes.Buffer)
|
||||||
|
defer bufferPool.Put(buf)
|
||||||
|
buf.Reset()
|
||||||
|
|
||||||
|
buf.Write(qe.Query)
|
||||||
|
|
||||||
|
httpRequest, err := http.NewRequest(http.MethodPost, qe.endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
qe.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Can't create HTTP request",
|
||||||
|
Pairs: map[string]interface{}{"error": err.Error()},
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("can't create HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range qe.Headers {
|
||||||
|
httpRequest.Header.Set(key, fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
// Set Content-Type header if not already set
|
||||||
|
if httpRequest.Header.Get("Content-Type") == "" {
|
||||||
|
httpRequest.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly remove Accept-Encoding header to prevent automatic request compression
|
||||||
|
// Response decompression is still handled intelligently based on Content-Encoding header
|
||||||
|
// and gzip magic bytes detection in the response processing logic below
|
||||||
|
httpRequest.Header.Set("Accept-Encoding", "identity")
|
||||||
|
|
||||||
|
retriesMax := 1
|
||||||
|
if qe.Retries {
|
||||||
|
retriesMax = qe.retries_number
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryResult queryResults
|
||||||
|
err = retry.Do(
|
||||||
|
func() error {
|
||||||
|
// Reset buffer before each retry
|
||||||
|
buf.Reset()
|
||||||
|
buf.Write(qe.Query)
|
||||||
|
|
||||||
|
// Comprehensive request body analysis for trailing garbage debugging
|
||||||
|
requestBody := buf.Bytes()
|
||||||
|
qe.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Request body analysis",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"body_size": len(requestBody),
|
||||||
|
"contains_jsonQuery": bytes.Contains(requestBody, []byte(`"jsonQuery"`)),
|
||||||
|
"contains_base64": bytes.Contains(requestBody, []byte("eyJ")),
|
||||||
|
"first_100_chars": string(requestBody[:min(100, len(requestBody))]),
|
||||||
|
"last_50_chars": string(requestBody[max(0, len(requestBody)-50):]),
|
||||||
|
"is_valid_utf8": utf8.Valid(requestBody),
|
||||||
|
"has_null_bytes": bytes.Contains(requestBody, []byte{0}),
|
||||||
|
"has_control_chars": hasControlChars(requestBody),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validate request body structure
|
||||||
|
if err := validateRequestBody(requestBody, qe.Logger); err != nil {
|
||||||
|
qe.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Request body validation failed",
|
||||||
|
Pairs: map[string]interface{}{"error": err.Error()},
|
||||||
|
})
|
||||||
|
return fmt.Errorf("request body validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the body of the request (plain JSON, no compression)
|
||||||
|
httpRequest.Body = io.NopCloser(bytes.NewReader(requestBody))
|
||||||
|
httpRequest.ContentLength = int64(len(requestBody))
|
||||||
|
|
||||||
|
// Debug log to confirm request is sent as plain JSON
|
||||||
|
qe.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Sending GraphQL request as plain JSON",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"content_length": httpRequest.ContentLength,
|
||||||
|
"content_type": httpRequest.Header.Get("Content-Type"),
|
||||||
|
"compression_disabled": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Client should always be set via NewConnection(), but check for safety
|
||||||
|
if qe.client == nil {
|
||||||
|
qe.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "HTTP client not initialized - use NewConnection() to create client",
|
||||||
|
Pairs: nil,
|
||||||
|
})
|
||||||
|
return fmt.Errorf("HTTP client not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResponse, err := qe.client.Do(httpRequest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
io.Copy(io.Discard, httpResponse.Body)
|
||||||
|
httpResponse.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return fmt.Errorf("HTTP error - status code: %s for %s", httpResponse.Status, httpRequest.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log Content-Encoding header for debugging
|
||||||
|
encoding := httpResponse.Header.Get("Content-Encoding")
|
||||||
|
qe.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Processing response",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"content_encoding": encoding,
|
||||||
|
"content_type": httpResponse.Header.Get("Content-Type"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// First, read the entire response body into a buffer
|
||||||
|
rawBuf := bufferPool.Get().(*bytes.Buffer)
|
||||||
|
rawBuf.Reset()
|
||||||
|
defer bufferPool.Put(rawBuf)
|
||||||
|
|
||||||
|
_, err = io.Copy(rawBuf, httpResponse.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading HTTP response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawData := rawBuf.Bytes()
|
||||||
|
qe.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Read raw response data",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"response_size": len(rawData),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use buffer pool for final data processing
|
||||||
|
respBuf := bufferPool.Get().(*bytes.Buffer)
|
||||||
|
respBuf.Reset()
|
||||||
|
defer bufferPool.Put(respBuf)
|
||||||
|
|
||||||
|
var finalData []byte
|
||||||
|
var detectionMethod string
|
||||||
|
|
||||||
|
// Intelligent content detection: Check for gzip magic bytes regardless of headers
|
||||||
|
// This handles buggy servers that send incorrect Content-Encoding headers
|
||||||
|
isGzipData := len(rawData) >= 2 && rawData[0] == 0x1f && rawData[1] == 0x8b
|
||||||
|
|
||||||
|
logPairs := map[string]interface{}{
|
||||||
|
"content_encoding_header": encoding,
|
||||||
|
"has_gzip_magic_bytes": isGzipData,
|
||||||
|
"data_size": len(rawData),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add first_two_bytes if we have data
|
||||||
|
if len(rawData) >= 2 {
|
||||||
|
logPairs["first_two_bytes"] = fmt.Sprintf("0x%02x 0x%02x", rawData[0], rawData[1])
|
||||||
|
} else if len(rawData) == 1 {
|
||||||
|
logPairs["first_byte"] = fmt.Sprintf("0x%02x", rawData[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
qe.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Content format detection",
|
||||||
|
Pairs: logPairs,
|
||||||
|
})
|
||||||
|
|
||||||
|
if isGzipData {
|
||||||
|
// Data has gzip magic bytes - attempt gzip decompression
|
||||||
|
detectionMethod = "gzip_magic_bytes"
|
||||||
|
gzipReader, err := gzip.NewReader(bytes.NewReader(rawData))
|
||||||
|
if err != nil {
|
||||||
|
qe.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Gzip magic bytes detected but failed to create gzip reader",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"gzip_reader_error": err.Error(),
|
||||||
|
"content_encoding_header": encoding,
|
||||||
|
"detection_method": detectionMethod,
|
||||||
|
"raw_size": len(rawData),
|
||||||
|
"first_two_bytes": fmt.Sprintf("0x%02x 0x%02x", rawData[0], rawData[1]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return fmt.Errorf("gzip reader creation failed: data has gzip magic bytes but gzip.NewReader error: %w", err)
|
||||||
|
} else {
|
||||||
|
// Successfully created gzip reader, now try to decompress
|
||||||
|
defer gzipReader.Close()
|
||||||
|
|
||||||
|
_, copyErr := io.Copy(respBuf, gzipReader)
|
||||||
|
if copyErr != nil {
|
||||||
|
// Gzip decompression failed - this is an error condition, not a fallback scenario
|
||||||
|
qe.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Gzip magic bytes detected but decompression failed",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"gzip_decompress_error": copyErr.Error(),
|
||||||
|
"raw_size": len(rawData),
|
||||||
|
"content_encoding_header": encoding,
|
||||||
|
"detection_method": detectionMethod,
|
||||||
|
"first_two_bytes": fmt.Sprintf("0x%02x 0x%02x", rawData[0], rawData[1]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return fmt.Errorf("gzip decompression failed: data has gzip magic bytes but decompression error: %w", copyErr)
|
||||||
|
} else {
|
||||||
|
// Successful decompression
|
||||||
|
finalData = respBuf.Bytes()
|
||||||
|
qe.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Successfully decompressed gzip response using magic byte detection",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"compressed_size": len(rawData),
|
||||||
|
"decompressed_size": len(finalData),
|
||||||
|
"content_encoding_header": encoding,
|
||||||
|
"detection_method": detectionMethod,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if encoding == "gzip" {
|
||||||
|
// Header claims gzip but no magic bytes - check if client already handled decompression
|
||||||
|
detectionMethod = "header_claims_gzip_no_magic_bytes"
|
||||||
|
|
||||||
|
// Check if this looks like valid JSON (likely already decompressed by client)
|
||||||
|
trimmed := bytes.TrimSpace(rawData)
|
||||||
|
if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') {
|
||||||
|
qe.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Content-Encoding header indicates gzip but data appears to be JSON - likely auto-decompressed by HTTP client",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"content_encoding_header": encoding,
|
||||||
|
"detection_method": detectionMethod,
|
||||||
|
"data_starts_with_json": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
finalData = rawData
|
||||||
|
} else {
|
||||||
|
qe.Logger.Warning(&libpack_logger.LogMessage{
|
||||||
|
Message: "Content-Encoding header indicates gzip but no magic bytes and data doesn't look like JSON",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"content_encoding_header": encoding,
|
||||||
|
"detection_method": detectionMethod,
|
||||||
|
"first_few_bytes": string(rawData[:min(50, len(rawData))]),
|
||||||
|
"data_size": len(rawData),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return fmt.Errorf("content encoding mismatch: header claims gzip but data has no gzip magic bytes and doesn't appear to be valid JSON")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No compression indicated by header and no gzip magic bytes
|
||||||
|
detectionMethod = "plain_json"
|
||||||
|
qe.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Processing as plain JSON response",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"content_encoding_header": encoding,
|
||||||
|
"detection_method": detectionMethod,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
finalData = rawData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we have data before attempting JSON unmarshaling
|
||||||
|
if len(finalData) == 0 {
|
||||||
|
return fmt.Errorf("empty response data after processing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the first few bytes for debugging (truncated for safety)
|
||||||
|
debugData := finalData
|
||||||
|
if len(debugData) > 100 {
|
||||||
|
debugData = debugData[:100]
|
||||||
|
}
|
||||||
|
qe.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Attempting JSON unmarshaling",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"data_size": len(finalData),
|
||||||
|
"data_sample": string(debugData),
|
||||||
|
"detection_method": detectionMethod,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unmarshal the final processed data
|
||||||
|
err = json.Unmarshal(finalData, &queryResult)
|
||||||
|
if err != nil {
|
||||||
|
qe.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "JSON unmarshaling failed",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"data_size": len(finalData),
|
||||||
|
"data_sample": string(debugData),
|
||||||
|
"encoding": encoding,
|
||||||
|
"detection_method": detectionMethod,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return fmt.Errorf("error unmarshalling HTTP response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for GraphQL errors in the response
|
||||||
|
if len(queryResult.Errors) > 0 {
|
||||||
|
// Determine if this is a retryable error based on configured patterns
|
||||||
|
if isRetryableError(queryResult.Errors, qe.retries_patterns) {
|
||||||
|
// Log as warning since we'll retry
|
||||||
|
qe.Logger.Warning(&libpack_logger.LogMessage{
|
||||||
|
Message: "Retryable GraphQL error detected",
|
||||||
|
Pairs: map[string]interface{}{"errors": queryResult.Errors},
|
||||||
|
})
|
||||||
|
// Return error to trigger retry mechanism
|
||||||
|
return fmt.Errorf("retryable error executing query: %s", queryResult.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-retryable error - log and fail immediately without retrying
|
||||||
|
qe.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Non-retryable GraphQL error",
|
||||||
|
Pairs: map[string]interface{}{"errors": queryResult.Errors},
|
||||||
|
})
|
||||||
|
// Use retry.Unrecoverable to skip remaining retry attempts
|
||||||
|
return retry.Unrecoverable(fmt.Errorf("error executing query: %s", queryResult.Errors))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for null data in the response
|
||||||
|
if queryResult.Data == nil {
|
||||||
|
qe.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "GraphQL query returned no data",
|
||||||
|
Pairs: map[string]interface{}{"error": "data field is null"},
|
||||||
|
})
|
||||||
|
// Null data is typically not retryable (indicates query structure issue)
|
||||||
|
return retry.Unrecoverable(errors.New("error executing query: no data"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
retry.OnRetry(func(n uint, err error) {
|
||||||
|
qe.Logger.Warning(&libpack_logger.LogMessage{
|
||||||
|
Message: "Retrying query",
|
||||||
|
Pairs: map[string]interface{}{"error": err.Error(), "attempt": n + 1},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
retry.Attempts(uint(retriesMax)),
|
||||||
|
retry.DelayType(retry.BackOffDelay),
|
||||||
|
retry.Delay(time.Duration(qe.retries_delay)),
|
||||||
|
retry.MaxDelay(10*time.Second),
|
||||||
|
retry.LastErrorOnly(true),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
qe.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Query execution failed after retries",
|
||||||
|
Pairs: map[string]interface{}{"error": err.Error()},
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point, we have successfully executed the query and validated the response
|
||||||
|
// Marshal queryResult.Data
|
||||||
|
jsonData, err := json.Marshal(queryResult.Data)
|
||||||
|
if err != nil {
|
||||||
|
qe.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Error marshalling query result",
|
||||||
|
Pairs: map[string]interface{}{"error": err.Error(), "data": queryResult.Data},
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("error marshalling query result: %w. Data: %s", err, queryResult.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if qe.CacheKey != "no-cache" {
|
||||||
|
qe.cache.Set(qe.CacheKey, jsonData, qe.CacheTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasControlChars checks if the byte slice contains control characters that might cause parsing issues
|
||||||
|
func hasControlChars(data []byte) bool {
|
||||||
|
for _, b := range data {
|
||||||
|
// Check for control characters except for common whitespace (tab, newline, carriage return)
|
||||||
|
if b < 32 && b != 9 && b != 10 && b != 13 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRequestBody performs comprehensive validation of the request body
|
||||||
|
func validateRequestBody(data []byte, logger *libpack_logger.Logger) error {
|
||||||
|
// Check if data is valid UTF-8
|
||||||
|
if !utf8.Valid(data) {
|
||||||
|
return fmt.Errorf("request body contains invalid UTF-8 sequences")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for null bytes
|
||||||
|
if bytes.Contains(data, []byte{0}) {
|
||||||
|
return fmt.Errorf("request body contains null bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's valid JSON
|
||||||
|
var jsonData map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &jsonData); err != nil {
|
||||||
|
return fmt.Errorf("request body is not valid JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for jsonQuery field (this should never be present)
|
||||||
|
if _, exists := jsonData["jsonQuery"]; exists {
|
||||||
|
logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "CRITICAL: jsonQuery field found in request body - this causes trailing garbage",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"body_size": len(data),
|
||||||
|
"jsonQuery_present": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return fmt.Errorf("jsonQuery field found in request body - this would cause trailing garbage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unexpected base64 data that might indicate double encoding
|
||||||
|
if bytes.Contains(data, []byte("eyJ")) {
|
||||||
|
logger.Warning(&libpack_logger.LogMessage{
|
||||||
|
Message: "Potential base64 encoded JSON detected in request body",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"body_size": len(data),
|
||||||
|
"contains_base64": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log successful validation
|
||||||
|
logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Request body validation passed",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"body_size": len(data),
|
||||||
|
"valid_utf8": true,
|
||||||
|
"valid_json": true,
|
||||||
|
"no_jsonQuery_field": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,932 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (suite *Tests) TestQueryExecutor_executeQuery() {
|
||||||
|
suite.T().Run("should execute query successfully", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "test-key",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = mockServer.URL
|
||||||
|
qe.client = mockServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
qe.CacheTTL = 5 * time.Second
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle HTTP errors", func(t *testing.T) {
|
||||||
|
// Create a test server that returns an error
|
||||||
|
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("Internal Server Error"))
|
||||||
|
}))
|
||||||
|
defer errorServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = errorServer.URL
|
||||||
|
qe.client = errorServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "HTTP error")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle GraphQL errors", func(t *testing.T) {
|
||||||
|
// Create a test server that returns GraphQL errors
|
||||||
|
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"errors":[{"message":"Field not found"}]}`))
|
||||||
|
}))
|
||||||
|
defer errorServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { invalidField }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = errorServer.URL
|
||||||
|
qe.client = errorServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "error executing query")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle no data response", func(t *testing.T) {
|
||||||
|
// Create a test server that returns no data
|
||||||
|
noDataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":null}`))
|
||||||
|
}))
|
||||||
|
defer noDataServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = noDataServer.URL
|
||||||
|
qe.client = noDataServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "no data")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle gzip compressed responses", func(t *testing.T) {
|
||||||
|
// Create a test server that returns gzip compressed data
|
||||||
|
gzipServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&buf)
|
||||||
|
gzipWriter.Write([]byte(`{"data":{"viewer":{"login":"testuser"}}}`))
|
||||||
|
gzipWriter.Close()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}))
|
||||||
|
defer gzipServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "test-gzip",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = gzipServer.URL
|
||||||
|
qe.client = gzipServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
qe.CacheTTL = 5 * time.Second
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Contains(string(result), "testuser")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle retries", func(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
retryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts < 3 {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"viewer":{"login":"retryuser"}}}`))
|
||||||
|
}))
|
||||||
|
defer retryServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: true,
|
||||||
|
}
|
||||||
|
qe.endpoint = retryServer.URL
|
||||||
|
qe.client = retryServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
qe.retries_number = 5
|
||||||
|
qe.retries_delay = 100 * time.Millisecond
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Contains(string(result), "retryuser")
|
||||||
|
assert.Equal(3, attempts) // Should have retried twice before succeeding
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should return error when client not initialized", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = mockServer.URL
|
||||||
|
qe.client = nil // No client provided (should always use NewConnection())
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "HTTP client not initialized")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should set default Content-Type header", func(t *testing.T) {
|
||||||
|
var receivedHeaders http.Header
|
||||||
|
headerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedHeaders = r.Header
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"viewer":{"login":"headeruser"}}}`))
|
||||||
|
}))
|
||||||
|
defer headerServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{}, // No Content-Type header
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = headerServer.URL
|
||||||
|
qe.client = headerServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Equal("application/json; charset=utf-8", receivedHeaders.Get("Content-Type"))
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle invalid JSON response", func(t *testing.T) {
|
||||||
|
invalidJSONServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`invalid json response`))
|
||||||
|
}))
|
||||||
|
defer invalidJSONServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = invalidJSONServer.URL
|
||||||
|
qe.client = invalidJSONServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "error unmarshalling HTTP response")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should cache successful responses", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "cache-test-key",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = mockServer.URL
|
||||||
|
qe.client = mockServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
qe.CacheTTL = 5 * time.Second
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
|
||||||
|
// Check that the result was cached
|
||||||
|
cachedResult := client.cacheLookup("cache-test-key")
|
||||||
|
assert.NotNil(cachedResult)
|
||||||
|
assert.Equal(result, cachedResult)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestQueryExecutor_executeQuery_requestCreationError() {
|
||||||
|
suite.T().Run("should handle request creation errors", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = "://invalid-url" // Invalid URL to trigger error
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "can't create HTTP request")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestQueryExecutor_executeQuery_gzipTrailingGarbage() {
|
||||||
|
suite.T().Run("should handle gzip trailing garbage error with fallback", func(t *testing.T) {
|
||||||
|
// Create a test server that returns malformed gzip data (with trailing garbage)
|
||||||
|
malformedGzipServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
// Create valid gzip data first
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&buf)
|
||||||
|
gzipWriter.Write([]byte(`{"data":{"viewer":{"login":"testuser"}}}`))
|
||||||
|
gzipWriter.Close()
|
||||||
|
|
||||||
|
// Add trailing garbage to simulate the error
|
||||||
|
validGzipData := buf.Bytes()
|
||||||
|
malformedData := append(validGzipData, []byte("trailing garbage data")...)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(malformedData)
|
||||||
|
}))
|
||||||
|
defer malformedGzipServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = malformedGzipServer.URL
|
||||||
|
qe.client = malformedGzipServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
// This should fail with gzip decompression error
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
// Should get proper gzip decompression error instead of trying to parse raw gzip as JSON
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "gzip decompression failed")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle gzip with valid JSON fallback", func(t *testing.T) {
|
||||||
|
// Create a test server that claims gzip but returns plain JSON
|
||||||
|
fakeGzipServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip") // Claims gzip but sends plain JSON
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"viewer":{"login":"fallbackuser"}}}`))
|
||||||
|
}))
|
||||||
|
defer fakeGzipServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = fakeGzipServer.URL
|
||||||
|
qe.client = fakeGzipServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
// Should fail gzip decompression but succeed with fallback to plain JSON
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Contains(string(result), "fallbackuser")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle corrupted gzip header", func(t *testing.T) {
|
||||||
|
// Create a test server that returns corrupted gzip data
|
||||||
|
corruptedGzipServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
// Send corrupted gzip data (invalid magic number)
|
||||||
|
corruptedData := []byte{0x1f, 0x8c, 0x08, 0x00} // Invalid gzip header
|
||||||
|
corruptedData = append(corruptedData, []byte(`{"data":{"viewer":{"login":"corruptuser"}}}`)...)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(corruptedData)
|
||||||
|
}))
|
||||||
|
defer corruptedGzipServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = corruptedGzipServer.URL
|
||||||
|
qe.client = corruptedGzipServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
// Should fail gzip reader creation and fallback to raw data parsing
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
// This will likely fail because the raw data includes corrupted gzip headers
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle empty gzip response", func(t *testing.T) {
|
||||||
|
// Create a test server that returns empty gzip data
|
||||||
|
emptyGzipServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
// Send empty response
|
||||||
|
}))
|
||||||
|
defer emptyGzipServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = emptyGzipServer.URL
|
||||||
|
qe.client = emptyGzipServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "content encoding mismatch")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle partial gzip data", func(t *testing.T) {
|
||||||
|
// Create a test server that returns partial/truncated gzip data
|
||||||
|
partialGzipServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
// Create valid gzip data but truncate it
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&buf)
|
||||||
|
gzipWriter.Write([]byte(`{"data":{"viewer":{"login":"partialuser"}}}`))
|
||||||
|
gzipWriter.Close()
|
||||||
|
|
||||||
|
// Send only partial gzip data (truncated)
|
||||||
|
fullData := buf.Bytes()
|
||||||
|
partialData := fullData[:len(fullData)/2] // Send only half
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(partialData)
|
||||||
|
}))
|
||||||
|
defer partialGzipServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = partialGzipServer.URL
|
||||||
|
qe.client = partialGzipServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
// Should fail gzip decompression and fallback, but raw data won't be valid JSON
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should successfully decompress valid gzip", func(t *testing.T) {
|
||||||
|
// Ensure our fix doesn't break valid gzip handling
|
||||||
|
validGzipServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&buf)
|
||||||
|
gzipWriter.Write([]byte(`{"data":{"viewer":{"login":"validgzipuser"}}}`))
|
||||||
|
gzipWriter.Close()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}))
|
||||||
|
defer validGzipServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "test-valid-gzip",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = validGzipServer.URL
|
||||||
|
qe.client = validGzipServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
qe.CacheTTL = 5 * time.Second
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Contains(string(result), "validgzipuser")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestQueryExecutor_executeQuery_gzipErrorScenarios() {
|
||||||
|
suite.T().Run("should handle gzip reader creation failure", func(t *testing.T) {
|
||||||
|
// Test server that sends invalid gzip magic number
|
||||||
|
invalidMagicServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
// Send data with wrong magic number (not gzip)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"viewer":{"login":"magicuser"}}}`))
|
||||||
|
}))
|
||||||
|
defer invalidMagicServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = invalidMagicServer.URL
|
||||||
|
qe.client = invalidMagicServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
// Should fail gzip reader creation, fallback to raw JSON parsing, and succeed
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Contains(string(result), "magicuser")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle gzip decompression with unexpected EOF", func(t *testing.T) {
|
||||||
|
// Create server that sends truncated gzip stream
|
||||||
|
eofGzipServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
// Create a proper gzip header but truncate the data
|
||||||
|
gzipHeader := []byte{0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff}
|
||||||
|
// Add some compressed data but not complete
|
||||||
|
incompleteData := []byte{0x4a, 0x49, 0x2c, 0x49, 0x54, 0xb2, 0x52, 0x50}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(append(gzipHeader, incompleteData...))
|
||||||
|
}))
|
||||||
|
defer eofGzipServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = eofGzipServer.URL
|
||||||
|
qe.client = eofGzipServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
// Should fail gzip decompression with proper error message
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "gzip decompression failed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestQueryExecutor_executeQuery_intelligentGzipDetection() {
|
||||||
|
suite.T().Run("should detect gzip by magic bytes when header is missing", func(t *testing.T) {
|
||||||
|
// Server sends gzip data but forgets to set Content-Encoding header
|
||||||
|
missingHeaderServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// Intentionally NOT setting Content-Encoding header
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&buf)
|
||||||
|
gzipWriter.Write([]byte(`{"data":{"viewer":{"login":"magicbytesuser"}}}`))
|
||||||
|
gzipWriter.Close()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}))
|
||||||
|
defer missingHeaderServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = missingHeaderServer.URL
|
||||||
|
qe.client = missingHeaderServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Contains(string(result), "magicbytesuser")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle server that claims gzip but sends plain JSON", func(t *testing.T) {
|
||||||
|
// Buggy server that sets gzip header but sends plain JSON
|
||||||
|
buggyHeaderServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip") // Claims gzip but sends plain JSON
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"viewer":{"login":"buggyheaderuser"}}}`))
|
||||||
|
}))
|
||||||
|
defer buggyHeaderServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = buggyHeaderServer.URL
|
||||||
|
qe.client = buggyHeaderServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Contains(string(result), "buggyheaderuser")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle randomly switching server behavior", func(t *testing.T) {
|
||||||
|
// Simulate a server that randomly switches between gzip and plain responses
|
||||||
|
requestCount := 0
|
||||||
|
randomServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCount++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if requestCount%2 == 0 {
|
||||||
|
// Even requests: send gzip with correct header
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&buf)
|
||||||
|
gzipWriter.Write([]byte(`{"data":{"viewer":{"login":"randomgzipuser"}}}`))
|
||||||
|
gzipWriter.Close()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
} else {
|
||||||
|
// Odd requests: send plain JSON without header
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"viewer":{"login":"randomplainuser"}}}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer randomServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test multiple requests to simulate random behavior
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = randomServer.URL
|
||||||
|
qe.client = randomServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
|
||||||
|
// Should handle both gzip and plain responses correctly
|
||||||
|
resultStr := string(result)
|
||||||
|
// Just check that we got some valid JSON response with expected user data
|
||||||
|
assert.True(len(resultStr) > 0, "Result should not be empty")
|
||||||
|
assert.True(
|
||||||
|
(len(resultStr) > 10 && (resultStr[0] == '{' || resultStr[0] == '"')) ||
|
||||||
|
len(resultStr) > 5,
|
||||||
|
"Result should be valid JSON or contain user data, got: %s", resultStr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle server with wrong Content-Encoding and gzip data", func(t *testing.T) {
|
||||||
|
// Server sends gzip data but claims it's not compressed
|
||||||
|
wrongEncodingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "identity") // Claims no compression
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&buf)
|
||||||
|
gzipWriter.Write([]byte(`{"data":{"viewer":{"login":"wrongencodinguser"}}}`))
|
||||||
|
gzipWriter.Close()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}))
|
||||||
|
defer wrongEncodingServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = wrongEncodingServer.URL
|
||||||
|
qe.client = wrongEncodingServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Contains(string(result), "wrongencodinguser")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle server with multiple encoding headers", func(t *testing.T) {
|
||||||
|
// Server sends conflicting headers but actual gzip data
|
||||||
|
conflictingHeaderServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Add("Content-Encoding", "identity")
|
||||||
|
w.Header().Add("Content-Encoding", "gzip") // Conflicting headers
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&buf)
|
||||||
|
gzipWriter.Write([]byte(`{"data":{"viewer":{"login":"conflictinguser"}}}`))
|
||||||
|
gzipWriter.Close()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}))
|
||||||
|
defer conflictingHeaderServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = conflictingHeaderServer.URL
|
||||||
|
qe.client = conflictingHeaderServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Contains(string(result), "conflictinguser")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle empty response with gzip header", func(t *testing.T) {
|
||||||
|
// Server claims gzip but sends empty response
|
||||||
|
emptyGzipHeaderServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
// Send empty response
|
||||||
|
}))
|
||||||
|
defer emptyGzipHeaderServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = emptyGzipHeaderServer.URL
|
||||||
|
qe.client = emptyGzipHeaderServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "content encoding mismatch")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle response with only gzip magic bytes but no valid gzip data", func(t *testing.T) {
|
||||||
|
// Server sends gzip magic bytes but invalid gzip stream
|
||||||
|
invalidGzipServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// No Content-Encoding header
|
||||||
|
|
||||||
|
// Send gzip magic bytes followed by invalid data
|
||||||
|
invalidData := []byte{0x1f, 0x8b} // Gzip magic bytes
|
||||||
|
invalidData = append(invalidData, []byte("invalid gzip stream data")...)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(invalidData)
|
||||||
|
}))
|
||||||
|
defer invalidGzipServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = invalidGzipServer.URL
|
||||||
|
qe.client = invalidGzipServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
// Should detect gzip magic bytes, fail gzip reader creation with proper error
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "gzip reader creation failed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestQueryExecutor_executeQuery_contentDetectionLogging() {
|
||||||
|
suite.T().Run("should log detection method for various scenarios", func(t *testing.T) {
|
||||||
|
// Test that our enhanced logging includes detection method information
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
setupServer func() *httptest.Server
|
||||||
|
expectedInLogs string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "gzip_magic_bytes_detection",
|
||||||
|
setupServer: func() *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// No Content-Encoding header
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&buf)
|
||||||
|
gzipWriter.Write([]byte(`{"data":{"viewer":{"login":"logtest1"}}}`))
|
||||||
|
gzipWriter.Close()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
expectedInLogs: "logtest1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "header_claims_gzip_but_plain_json",
|
||||||
|
setupServer: func() *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"viewer":{"login":"logtest2"}}}`))
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
expectedInLogs: "logtest2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain_json_detection",
|
||||||
|
setupServer: func() *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"viewer":{"login":"logtest3"}}}`))
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
expectedInLogs: "logtest3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
server := scenario.setupServer()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = server.URL
|
||||||
|
qe.client = server.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Contains(string(result), scenario.expectedInLogs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
module github.com/lukaszraczylo/go-simple-graphql
|
module github.com/lukaszraczylo/go-simple-graphql
|
||||||
|
|
||||||
go 1.17
|
go 1.23.0
|
||||||
|
|
||||||
|
toolchain go1.23.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/buger/jsonparser v1.1.1
|
github.com/avast/retry-go/v4 v4.6.1
|
||||||
github.com/json-iterator/go v1.1.12
|
github.com/goccy/go-json v0.10.5
|
||||||
github.com/lukaszraczylo/pandati v0.0.10
|
github.com/goccy/go-reflect v1.2.0
|
||||||
github.com/rs/zerolog v1.25.0
|
github.com/gookit/goutil v0.6.18
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/net v0.0.0-20210929193557-e81a3d93ecf6
|
golang.org/x/net v0.40.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/imdario/mergo v0.3.12 // indirect
|
github.com/gookit/color v1.5.4 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
golang.org/x/text v0.3.6 // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
golang.org/x/term v0.32.0 // indirect
|
||||||
|
golang.org/x/text v0.25.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,62 +1,34 @@
|
|||||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
|
||||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
|
||||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
|
github.com/goccy/go-reflect v1.2.0 h1:O0T8rZCuNmGXewnATuKYnkL0xm6o8UNOJZd/gOkb9ms=
|
||||||
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
github.com/goccy/go-reflect v1.2.0/go.mod h1:n0oYZn8VcV2CkWTxi8B9QjkCoq6GTtCEdfmR66YhFtE=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||||
github.com/lukaszraczylo/pandati v0.0.10 h1:ZXmLjxb/UYRBVyAKwXmUcJgZ2FYNwGY4YDJsQQMYAXI=
|
github.com/gookit/goutil v0.6.18 h1:MUVj0G16flubWT8zYVicIuisUiHdgirPAkmnfD2kKgw=
|
||||||
github.com/lukaszraczylo/pandati v0.0.10/go.mod h1:1lE/x4X4LfO1yerAQoiR7AVb/wIabH/zITDGnqTdyH8=
|
github.com/gookit/goutil v0.6.18/go.mod h1:AY/5sAwKe7Xck+mEbuxj0n/bc3qwrGNe3Oeulln7zBA=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/rs/zerolog v1.25.0 h1:Rj7XygbUHKUlDPcVdoLyR91fJBsduXj5fRxyqIQj/II=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/rs/zerolog v1.25.0/go.mod h1:7KHcEGe0QZPOm2IE4Kpb5rTh6n1h2hIgS5OOnu1rUaI=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||||
golang.org/x/net v0.0.0-20210929193557-e81a3d93ecf6 h1:Z04ewVs7JhXaYkmDhBERPi41gnltfQpMWDnTnQbaCqk=
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/net v0.0.0-20210929193557-e81a3d93ecf6/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
// Package go-simple-graphql is a wrapper for GraphQL queries execution.
|
|
||||||
// It's sole purpose is to allow developer to execute queries without specific fields / types mapping
|
|
||||||
// which can be really confusing and even impossible whilst dealing with Hasura custom generated types.
|
|
||||||
//
|
|
||||||
// Library supports advanced error reporting on unsuccessful queries.
|
|
||||||
// Library also uses HTTP2 for communication with GraphQL API if it's supported by server
|
|
||||||
|
|
||||||
// Environment variables:
|
|
||||||
// GRAPHQL_ENDPOINT - GraphQL endpoint to use. Default: http://127.0.0.1:9090/v1/graphql
|
|
||||||
// LOG_LEVEL - Log level to use. Default: INFO
|
|
||||||
// Available log levels: DEBUG, INFO, WARN, ERROR, FATAL
|
|
||||||
|
|
||||||
package gql
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
jsoniter "github.com/json-iterator/go"
|
|
||||||
"github.com/lukaszraczylo/go-simple-graphql/pkg/logging"
|
|
||||||
"golang.org/x/net/http2"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
json = jsoniter.ConfigCompatibleWithStandardLibrary
|
|
||||||
)
|
|
||||||
|
|
||||||
type GraphQL struct {
|
|
||||||
Endpoint string
|
|
||||||
HttpClient *http.Client
|
|
||||||
Log *logging.LogConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickGraphqlEndpoint() (graphqlEndpoint string) {
|
|
||||||
value, present := os.LookupEnv("GRAPHQL_ENDPOINT")
|
|
||||||
if present && value != "" {
|
|
||||||
graphqlEndpoint = value
|
|
||||||
} else {
|
|
||||||
graphqlEndpoint = "http://127.0.0.1:9090/v1/graphql"
|
|
||||||
fmt.Println("Setting default endpoint", graphqlEndpoint)
|
|
||||||
}
|
|
||||||
return graphqlEndpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewConnection() *GraphQL {
|
|
||||||
return &GraphQL{
|
|
||||||
Endpoint: pickGraphqlEndpoint(),
|
|
||||||
HttpClient: &http.Client{
|
|
||||||
Timeout: time.Second * 30,
|
|
||||||
Transport: &http2.Transport{
|
|
||||||
DisableCompression: false,
|
|
||||||
AllowHTTP: true,
|
|
||||||
DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
|
|
||||||
return net.Dial(network, addr)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Log: logging.NewLogger(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-66
@@ -1,66 +0,0 @@
|
|||||||
package gql
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
|
||||||
|
|
||||||
func mockGraphQLServerResponses(responseQuery string) (*httptest.Server, string) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
||||||
rw.Write([]byte(responseQuery))
|
|
||||||
}))
|
|
||||||
serverURL := server.URL + "/v1/graphql"
|
|
||||||
return server, serverURL
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *TestSuite) SetupTest() {
|
|
||||||
os.Unsetenv("GRAPHQL_ENDPOINT")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSuiteRun(t *testing.T) {
|
|
||||||
suite.Run(t, new(TestSuite))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *TestSuite) TestNewConnection() {
|
|
||||||
type args struct {
|
|
||||||
endpoint string
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
args args
|
|
||||||
want *GraphQL
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "New connection: Env variable endpoint",
|
|
||||||
args: args{
|
|
||||||
endpoint: "http://localhost:8080/graphql",
|
|
||||||
},
|
|
||||||
want: &GraphQL{
|
|
||||||
Endpoint: "http://localhost:8080/graphql",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "New connection: No env variable endpoint",
|
|
||||||
args: args{},
|
|
||||||
want: &GraphQL{
|
|
||||||
Endpoint: "http://127.0.0.1:9090/v1/graphql",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.T().Run(tt.name, func(t *testing.T) {
|
|
||||||
os.Setenv("GRAPHQL_ENDPOINT", tt.args.endpoint)
|
|
||||||
got := NewConnection()
|
|
||||||
assert.Equal(t, tt.want.Endpoint, got.Endpoint)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Benchmark_calculateHash(b *testing.B) {
|
||||||
|
client := NewConnection()
|
||||||
|
query := &Query{
|
||||||
|
Query: "query { viewer { login } }",
|
||||||
|
Variables: map[string]interface{}{"var1": "value1"},
|
||||||
|
}
|
||||||
|
query.JsonQuery = client.convertToJSON(query)
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = calculateHash(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Benchmark_calculateHashLargeQuery(b *testing.B) {
|
||||||
|
client := NewConnection()
|
||||||
|
// Create a larger query to test hash performance with bigger payloads
|
||||||
|
largeQuery := `query {
|
||||||
|
viewer {
|
||||||
|
login
|
||||||
|
name
|
||||||
|
email
|
||||||
|
bio
|
||||||
|
company
|
||||||
|
location
|
||||||
|
websiteUrl
|
||||||
|
twitterUsername
|
||||||
|
repositories(first: 100) {
|
||||||
|
nodes {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
url
|
||||||
|
stargazerCount
|
||||||
|
forkCount
|
||||||
|
primaryLanguage {
|
||||||
|
name
|
||||||
|
color
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
variables := make(map[string]interface{})
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
variables[fmt.Sprintf("var%d", i)] = fmt.Sprintf("value%d", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := &Query{
|
||||||
|
Query: largeQuery,
|
||||||
|
Variables: variables,
|
||||||
|
}
|
||||||
|
query.JsonQuery = client.convertToJSON(query)
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = calculateHash(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"hash/fnv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Benchmark comparing MD5 vs FNV hash performance
|
||||||
|
func BenchmarkHashAlgorithms(b *testing.B) {
|
||||||
|
testData := []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { id name email profile { avatar bio } posts { id title content createdAt } } }","variables":{"id":"12345"}}`)
|
||||||
|
|
||||||
|
b.Run("MD5", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
hash := md5.Sum(testData)
|
||||||
|
_ = hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("FNV64a", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
hash := fnv.New64a()
|
||||||
|
hash.Write(testData)
|
||||||
|
_ = hex.EncodeToString(hash.Sum(nil))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark the new calculateHash function
|
||||||
|
func BenchmarkCalculateHash(b *testing.B) {
|
||||||
|
query := &Query{
|
||||||
|
Query: "query GetUser($id: ID!) { user(id: $id) { id name email profile { avatar bio } posts { id title content createdAt } } }",
|
||||||
|
Variables: map[string]interface{}{
|
||||||
|
"id": "12345",
|
||||||
|
},
|
||||||
|
JsonQuery: []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { id name email profile { avatar bio } posts { id title content createdAt } } }","variables":{"id":"12345"}}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = calculateHash(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark hash collision rate (quality test)
|
||||||
|
func BenchmarkHashCollisions(b *testing.B) {
|
||||||
|
queries := make([]*Query, 1000)
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
queries[i] = &Query{
|
||||||
|
Query: "query GetUser($id: ID!) { user(id: $id) { id name email } }",
|
||||||
|
Variables: map[string]interface{}{
|
||||||
|
"id": i,
|
||||||
|
},
|
||||||
|
JsonQuery: []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { id name email } }","variables":{"id":` + string(rune(i)) + `}}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Run("FNV_Collision_Test", func(b *testing.B) {
|
||||||
|
hashes := make(map[string]bool)
|
||||||
|
collisions := 0
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
for _, query := range queries {
|
||||||
|
hash := calculateHash(query)
|
||||||
|
if hashes[hash] {
|
||||||
|
collisions++
|
||||||
|
} else {
|
||||||
|
hashes[hash] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.ReportMetric(float64(collisions), "collisions")
|
||||||
|
})
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
libpack_logger "github.com/lukaszraczylo/go-simple-graphql/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
func searchForKeysInMapStringInterface(msi map[string]interface{}, key string) interface{} {
|
||||||
|
if msi == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return msi[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateHash(query *Query) string {
|
||||||
|
hash := fnv.New64a()
|
||||||
|
hash.Write(query.JsonQuery)
|
||||||
|
return hex.EncodeToString(hash.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseClient) cacheLookup(hash string) []byte {
|
||||||
|
obj, _ := b.cache.Get(hash)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseClient) decodeResponse(response []byte) (any, error) {
|
||||||
|
switch b.responseType {
|
||||||
|
case "mapstring":
|
||||||
|
var result map[string]interface{}
|
||||||
|
err := json.Unmarshal(response, &result)
|
||||||
|
if err != nil {
|
||||||
|
b.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Can't decode response into mapstring",
|
||||||
|
Pairs: map[string]interface{}{"error": err.Error()},
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case "string":
|
||||||
|
return string(response), nil
|
||||||
|
case "byte":
|
||||||
|
return response, nil
|
||||||
|
default:
|
||||||
|
b.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Can't decode response",
|
||||||
|
Pairs: map[string]interface{}{"error": "unknown response type"},
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("can't decode response - unknown response type specified")
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/goccy/go-reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (suite *Tests) Test_searchForKeysInMapStringInterface() {
|
||||||
|
type args struct {
|
||||||
|
msi map[string]interface{}
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
wantValue any
|
||||||
|
args args
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Test_searchForKeysInMapStringInterface_exists",
|
||||||
|
args: args{
|
||||||
|
msi: map[string]interface{}{
|
||||||
|
"gqlcache": true,
|
||||||
|
"test": "test2",
|
||||||
|
},
|
||||||
|
key: "test",
|
||||||
|
},
|
||||||
|
wantValue: "test2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Test_searchForKeysInMapStringInterface_not_exists",
|
||||||
|
args: args{
|
||||||
|
msi: map[string]interface{}{
|
||||||
|
"gqlcache": true,
|
||||||
|
"test": "test2",
|
||||||
|
},
|
||||||
|
key: "test-new",
|
||||||
|
},
|
||||||
|
wantValue: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(tt.wantValue, searchForKeysInMapStringInterface(tt.args.msi, tt.args.key))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_decodeResponse() {
|
||||||
|
type args struct {
|
||||||
|
response []byte
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
want any
|
||||||
|
name string
|
||||||
|
setType string
|
||||||
|
wantType string
|
||||||
|
args args
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "TestBaseClient_decodeResponse_mapstring",
|
||||||
|
args: args{
|
||||||
|
response: []byte(`{"data":{"viewer":{"login":"lukaszraczylo"}}}`),
|
||||||
|
},
|
||||||
|
want: map[string]interface{}{
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"viewer": map[string]interface{}{
|
||||||
|
"login": "lukaszraczylo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setType: "mapstring",
|
||||||
|
wantType: "map[string]interface {}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TestBaseClient_decodeResponse_string",
|
||||||
|
args: args{
|
||||||
|
response: []byte(`{"data":{"viewer":{"login":"lukaszraczylo"}}}`),
|
||||||
|
},
|
||||||
|
want: `{"data":{"viewer":{"login":"lukaszraczylo"}}}`,
|
||||||
|
setType: "string",
|
||||||
|
wantType: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TestBaseClient_decodeResponse_byte",
|
||||||
|
args: args{
|
||||||
|
response: []byte(`{"data":{"viewer":{"login":"lukaszraczylo"}}}`),
|
||||||
|
},
|
||||||
|
want: []byte(`{"data":{"viewer":{"login":"lukaszraczylo"}}}`),
|
||||||
|
setType: "byte",
|
||||||
|
wantType: "[]uint8",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
b.SetOutput(tt.setType)
|
||||||
|
got, err := b.decodeResponse(tt.args.response)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("decodeResponse() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(tt.wantType, reflect.TypeOf(got).String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) Test_searchForKeysInMapStringInterface_nil() {
|
||||||
|
suite.T().Run("should handle nil map", func(t *testing.T) {
|
||||||
|
result := searchForKeysInMapStringInterface(nil, "test")
|
||||||
|
assert.Nil(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) Test_calculateHash() {
|
||||||
|
suite.T().Run("should calculate consistent hash", func(t *testing.T) {
|
||||||
|
query1 := &Query{JsonQuery: []byte(`{"query": "{ user { name } }"}`)}
|
||||||
|
query2 := &Query{JsonQuery: []byte(`{"query": "{ user { name } }"}`)}
|
||||||
|
query3 := &Query{JsonQuery: []byte(`{"query": "{ user { email } }"}`)}
|
||||||
|
|
||||||
|
hash1 := calculateHash(query1)
|
||||||
|
hash2 := calculateHash(query2)
|
||||||
|
hash3 := calculateHash(query3)
|
||||||
|
|
||||||
|
// Same queries should produce same hash
|
||||||
|
assert.Equal(hash1, hash2)
|
||||||
|
// Different queries should produce different hashes
|
||||||
|
assert.NotEqual(hash1, hash3)
|
||||||
|
// Hash should be non-empty
|
||||||
|
assert.NotEmpty(hash1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_cacheLookup() {
|
||||||
|
suite.T().Run("should lookup cache entries", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test cache miss
|
||||||
|
result := client.cacheLookup("nonexistent")
|
||||||
|
assert.Nil(result)
|
||||||
|
|
||||||
|
// Test cache hit
|
||||||
|
testData := []byte("test data")
|
||||||
|
client.cache.Set("test_key", testData, 5*time.Second)
|
||||||
|
result = client.cacheLookup("test_key")
|
||||||
|
assert.Equal(testData, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_decodeResponse_errors() {
|
||||||
|
suite.T().Run("should handle invalid JSON for mapstring", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetOutput("mapstring")
|
||||||
|
|
||||||
|
_, err := client.decodeResponse([]byte(`invalid json`))
|
||||||
|
assert.Error(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle unknown response type", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.responseType = "unknown"
|
||||||
|
|
||||||
|
_, err := client.decodeResponse([]byte(`{"test": "data"}`))
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Contains(err.Error(), "unknown response type")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
libpack_logger "github.com/lukaszraczylo/go-simple-graphql/logging"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *BaseClient) createHttpClient() (http_client *http.Client) {
|
||||||
|
if strings.HasPrefix(b.endpoint, "http://") {
|
||||||
|
// Use HTTP/1.1 for http:// endpoints
|
||||||
|
// Note: h2c (HTTP/2 Cleartext) is rarely supported by servers
|
||||||
|
httpTransport := &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxConnsPerHost: 50,
|
||||||
|
MaxIdleConnsPerHost: 10,
|
||||||
|
IdleConnTimeout: 60 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 20 * time.Second,
|
||||||
|
DisableKeepAlives: false,
|
||||||
|
DisableCompression: true, // Disable automatic compression to prevent trailing garbage errors
|
||||||
|
WriteBufferSize: 4096,
|
||||||
|
ReadBufferSize: 4096,
|
||||||
|
// Additional settings for connection health
|
||||||
|
ExpectContinueTimeout: 2 * time.Second, // Timeout for 100-Continue
|
||||||
|
TLSHandshakeTimeout: 20 * time.Second, // Timeout for TLS handshake
|
||||||
|
}
|
||||||
|
|
||||||
|
http_client = &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
Transport: httpTransport,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Using HTTP/1.1 for http:// endpoint",
|
||||||
|
Pairs: nil,
|
||||||
|
})
|
||||||
|
} else if strings.HasPrefix(b.endpoint, "https://") {
|
||||||
|
// Use HTTP/2 for https:// endpoints
|
||||||
|
tlsClientConfig := &tls.Config{
|
||||||
|
InsecureSkipVerify: true, // TODO: Make this configurable via environment variable
|
||||||
|
}
|
||||||
|
|
||||||
|
http2Transport := &http2.Transport{
|
||||||
|
AllowHTTP: true,
|
||||||
|
TLSClientConfig: tlsClientConfig,
|
||||||
|
ReadIdleTimeout: 60 * time.Second, // Close idle connections after 60s
|
||||||
|
PingTimeout: 20 * time.Second, // Detect dead connections with PING
|
||||||
|
WriteByteTimeout: 20 * time.Second, // Timeout for write operations
|
||||||
|
DisableCompression: true, // Disable automatic compression to prevent trailing garbage errors
|
||||||
|
// StrictMaxConcurrentStreams forces the HTTP/2 connection to wait for SETTINGS frame
|
||||||
|
// This helps with connection health detection
|
||||||
|
StrictMaxConcurrentStreams: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
http_client = &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
Transport: http2Transport,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Using HTTP/2 for https:// endpoint",
|
||||||
|
Pairs: nil,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
b.Logger.Critical(&libpack_logger.LogMessage{
|
||||||
|
Message: "Invalid endpoint - must start with http:// or https://",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return http_client
|
||||||
|
}
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note: http.Transport import is kept for http.ErrUseLastResponse usage in redirect tests
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_createHttpClient() {
|
||||||
|
suite.T().Run("should create HTTP/1.1 client for http endpoints", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetEndpoint("http://example.com/graphql")
|
||||||
|
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
assert.NotNil(httpClient)
|
||||||
|
assert.Equal(60*time.Second, httpClient.Timeout)
|
||||||
|
assert.NotNil(httpClient.Transport)
|
||||||
|
|
||||||
|
// Check that it's using HTTP/1.1 transport (h2c is rarely supported by servers)
|
||||||
|
transport, ok := httpClient.Transport.(*http.Transport)
|
||||||
|
assert.True(ok)
|
||||||
|
assert.Equal(100, transport.MaxIdleConns)
|
||||||
|
assert.Equal(50, transport.MaxConnsPerHost)
|
||||||
|
assert.Equal(10, transport.MaxIdleConnsPerHost)
|
||||||
|
assert.Equal(60*time.Second, transport.IdleConnTimeout)
|
||||||
|
assert.Equal(20*time.Second, transport.ResponseHeaderTimeout)
|
||||||
|
assert.False(transport.DisableKeepAlives)
|
||||||
|
assert.True(transport.DisableCompression) // Request compression disabled to prevent "trailing garbage" errors
|
||||||
|
assert.Equal(4096, transport.WriteBufferSize)
|
||||||
|
assert.Equal(4096, transport.ReadBufferSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should create HTTP/2 client for https endpoints", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetEndpoint("https://example.com/graphql")
|
||||||
|
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
assert.NotNil(httpClient)
|
||||||
|
assert.Equal(60*time.Second, httpClient.Timeout)
|
||||||
|
assert.NotNil(httpClient.Transport)
|
||||||
|
|
||||||
|
// Check that it's using HTTP/2 transport
|
||||||
|
transport, ok := httpClient.Transport.(*http2.Transport)
|
||||||
|
assert.True(ok)
|
||||||
|
assert.True(transport.AllowHTTP)
|
||||||
|
assert.NotNil(transport.TLSClientConfig)
|
||||||
|
assert.True(transport.TLSClientConfig.InsecureSkipVerify)
|
||||||
|
assert.Equal(60*time.Second, transport.ReadIdleTimeout)
|
||||||
|
assert.Equal(20*time.Second, transport.PingTimeout)
|
||||||
|
assert.Equal(20*time.Second, transport.WriteByteTimeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle invalid endpoints", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetEndpoint("ftp://example.com/graphql")
|
||||||
|
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
assert.Nil(httpClient)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should set redirect policy", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetEndpoint("http://example.com/graphql")
|
||||||
|
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
assert.NotNil(httpClient)
|
||||||
|
assert.NotNil(httpClient.CheckRedirect)
|
||||||
|
|
||||||
|
// Test redirect policy
|
||||||
|
req, _ := http.NewRequest("GET", "http://example.com", nil)
|
||||||
|
err := httpClient.CheckRedirect(req, []*http.Request{})
|
||||||
|
assert.Equal(http.ErrUseLastResponse, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle empty endpoint", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetEndpoint("")
|
||||||
|
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
assert.Nil(httpClient)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle endpoint without protocol", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetEndpoint("example.com/graphql")
|
||||||
|
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
assert.Nil(httpClient)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_createHttpClient_transportSettings() {
|
||||||
|
suite.T().Run("should configure HTTP/1.1 transport correctly for http://", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetEndpoint("http://example.com/graphql")
|
||||||
|
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
transport := httpClient.Transport.(*http.Transport)
|
||||||
|
|
||||||
|
// Verify all HTTP/1.1 transport settings
|
||||||
|
assert.Equal(100, transport.MaxIdleConns)
|
||||||
|
assert.Equal(50, transport.MaxConnsPerHost)
|
||||||
|
assert.Equal(10, transport.MaxIdleConnsPerHost)
|
||||||
|
assert.Equal(60*time.Second, transport.IdleConnTimeout)
|
||||||
|
assert.Equal(20*time.Second, transport.ResponseHeaderTimeout)
|
||||||
|
assert.False(transport.DisableKeepAlives)
|
||||||
|
assert.True(transport.DisableCompression) // Request compression disabled to prevent "trailing garbage" errors
|
||||||
|
assert.Equal(4096, transport.WriteBufferSize)
|
||||||
|
assert.Equal(4096, transport.ReadBufferSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should configure HTTP/2 transport correctly", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetEndpoint("https://example.com/graphql")
|
||||||
|
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
transport := httpClient.Transport.(*http2.Transport)
|
||||||
|
|
||||||
|
// Verify all HTTP/2 transport settings
|
||||||
|
assert.True(transport.AllowHTTP)
|
||||||
|
assert.NotNil(transport.TLSClientConfig)
|
||||||
|
assert.True(transport.TLSClientConfig.InsecureSkipVerify)
|
||||||
|
assert.Equal(60*time.Second, transport.ReadIdleTimeout)
|
||||||
|
assert.Equal(20*time.Second, transport.PingTimeout)
|
||||||
|
assert.Equal(20*time.Second, transport.WriteByteTimeout)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_createHttpClient_logging() {
|
||||||
|
suite.T().Run("should log HTTP/1.1 usage", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetEndpoint("http://example.com/graphql")
|
||||||
|
|
||||||
|
// This test verifies that the function runs without error
|
||||||
|
// The actual logging is tested indirectly through the logger tests
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
assert.NotNil(httpClient)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should log HTTP/2 usage", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetEndpoint("https://example.com/graphql")
|
||||||
|
|
||||||
|
// This test verifies that the function runs without error
|
||||||
|
// The actual logging is tested indirectly through the logger tests
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
assert.NotNil(httpClient)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should log critical error for invalid endpoint", func(t *testing.T) {
|
||||||
|
client := NewConnection()
|
||||||
|
client.SetEndpoint("invalid://example.com/graphql")
|
||||||
|
|
||||||
|
// This test verifies that the function runs without error
|
||||||
|
// and returns nil for invalid endpoints (Critical() won't exit in tests)
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
assert.Nil(httpClient)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
assertions "github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestQueryJSONEncoding verifies that the Query struct serializes correctly
|
||||||
|
// without including the JsonQuery field, preventing "Trailing garbage" errors
|
||||||
|
func TestQueryJSONEncoding(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
query := &Query{
|
||||||
|
Query: "query GetUser($id: ID!) { user(id: $id) { id name } }",
|
||||||
|
Variables: map[string]interface{}{
|
||||||
|
"id": "123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set JsonQuery to simulate the compiled query
|
||||||
|
query.JsonQuery = client.convertToJSON(query)
|
||||||
|
|
||||||
|
// Serialize the query to JSON
|
||||||
|
jsonBytes, err := json.Marshal(query)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
// Parse the JSON back to verify structure
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
err = json.Unmarshal(jsonBytes, &parsed)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
// Verify that only query and variables are included, NOT jsonQuery
|
||||||
|
assert.Contains(parsed, "query")
|
||||||
|
assert.Contains(parsed, "variables")
|
||||||
|
assert.NotContains(parsed, "jsonQuery", "JsonQuery field should be excluded from JSON serialization")
|
||||||
|
|
||||||
|
// Verify the content is correct
|
||||||
|
assert.Equal(query.Query, parsed["query"])
|
||||||
|
assert.Equal(query.Variables["id"], parsed["variables"].(map[string]interface{})["id"])
|
||||||
|
|
||||||
|
// Verify the JSON is valid GraphQL format
|
||||||
|
assert.True(json.Valid(jsonBytes), "Generated JSON should be valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryJSONEncodingPreventsTrailingGarbage ensures the fix prevents the specific error
|
||||||
|
func TestQueryJSONEncodingPreventsTrailingGarbage(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Create a query with complex variables
|
||||||
|
query := &Query{
|
||||||
|
Query: "mutation storeMessage($content: String!, $userID: Int!) { insert_message(content: $content, user_id: $userID) { id } }",
|
||||||
|
Variables: map[string]interface{}{
|
||||||
|
"content": "Test message with special chars: {}[]\"'",
|
||||||
|
"userID": 12345,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile the query (this sets JsonQuery)
|
||||||
|
query.JsonQuery = client.convertToJSON(query)
|
||||||
|
|
||||||
|
// Serialize to JSON - this should NOT include JsonQuery field
|
||||||
|
jsonBytes, err := json.Marshal(query)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
// The JSON should be valid and parseable
|
||||||
|
var graphqlRequest map[string]interface{}
|
||||||
|
err = json.Unmarshal(jsonBytes, &graphqlRequest)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
// Should have exactly 2 fields: query and variables
|
||||||
|
assert.Len(graphqlRequest, 2)
|
||||||
|
assert.Contains(graphqlRequest, "query")
|
||||||
|
assert.Contains(graphqlRequest, "variables")
|
||||||
|
|
||||||
|
// The JSON should be suitable for sending to a GraphQL server
|
||||||
|
jsonString := string(jsonBytes)
|
||||||
|
assert.NotContains(jsonString, "jsonQuery", "JSON should not contain jsonQuery field")
|
||||||
|
assert.NotContains(jsonString, "eyJ", "JSON should not contain base64 encoded data")
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
logging "github.com/lukaszraczylo/go-simple-graphql/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogLevelEnvironmentIntegration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
logLevel string
|
||||||
|
expectedLevel int
|
||||||
|
shouldShowDebug bool
|
||||||
|
shouldShowInfo bool
|
||||||
|
shouldShowWarn bool
|
||||||
|
shouldShowError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Debug level shows all logs",
|
||||||
|
logLevel: "debug",
|
||||||
|
expectedLevel: logging.LEVEL_DEBUG,
|
||||||
|
shouldShowDebug: true,
|
||||||
|
shouldShowInfo: true,
|
||||||
|
shouldShowWarn: true,
|
||||||
|
shouldShowError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Info level shows info, warn, error",
|
||||||
|
logLevel: "info",
|
||||||
|
expectedLevel: logging.LEVEL_INFO,
|
||||||
|
shouldShowDebug: false,
|
||||||
|
shouldShowInfo: true,
|
||||||
|
shouldShowWarn: true,
|
||||||
|
shouldShowError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Warn level shows warn, error only",
|
||||||
|
logLevel: "warn",
|
||||||
|
expectedLevel: logging.LEVEL_WARN,
|
||||||
|
shouldShowDebug: false,
|
||||||
|
shouldShowInfo: false,
|
||||||
|
shouldShowWarn: true,
|
||||||
|
shouldShowError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Error level shows error only",
|
||||||
|
logLevel: "error",
|
||||||
|
expectedLevel: logging.LEVEL_ERROR,
|
||||||
|
shouldShowDebug: false,
|
||||||
|
shouldShowInfo: false,
|
||||||
|
shouldShowWarn: false,
|
||||||
|
shouldShowError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid level defaults to info",
|
||||||
|
logLevel: "invalid",
|
||||||
|
expectedLevel: logging.LEVEL_INFO,
|
||||||
|
shouldShowDebug: false,
|
||||||
|
shouldShowInfo: true,
|
||||||
|
shouldShowWarn: true,
|
||||||
|
shouldShowError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Set LOG_LEVEL environment variable
|
||||||
|
originalLogLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
defer os.Setenv("LOG_LEVEL", originalLogLevel)
|
||||||
|
|
||||||
|
os.Setenv("LOG_LEVEL", tt.logLevel)
|
||||||
|
|
||||||
|
// Test GetLogLevel function directly
|
||||||
|
parsedLevel := logging.GetLogLevel(tt.logLevel)
|
||||||
|
if parsedLevel != tt.expectedLevel {
|
||||||
|
t.Errorf("GetLogLevel(%s) = %d, want %d", tt.logLevel, parsedLevel, tt.expectedLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test logger with captured output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := logging.New()
|
||||||
|
logger.SetOutput(&buf)
|
||||||
|
logger.SetMinLogLevel(parsedLevel)
|
||||||
|
|
||||||
|
// Test all log levels
|
||||||
|
logger.Debug(&logging.LogMessage{Message: "debug message"})
|
||||||
|
logger.Info(&logging.LogMessage{Message: "info message"})
|
||||||
|
logger.Warn(&logging.LogMessage{Message: "warn message"})
|
||||||
|
logger.Error(&logging.LogMessage{Message: "error message"})
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// Check if expected messages appear in output
|
||||||
|
hasDebug := strings.Contains(output, "debug message")
|
||||||
|
hasInfo := strings.Contains(output, "info message")
|
||||||
|
hasWarn := strings.Contains(output, "warn message")
|
||||||
|
hasError := strings.Contains(output, "error message")
|
||||||
|
|
||||||
|
if hasDebug != tt.shouldShowDebug {
|
||||||
|
t.Errorf("Debug message visibility: got %v, want %v", hasDebug, tt.shouldShowDebug)
|
||||||
|
}
|
||||||
|
if hasInfo != tt.shouldShowInfo {
|
||||||
|
t.Errorf("Info message visibility: got %v, want %v", hasInfo, tt.shouldShowInfo)
|
||||||
|
}
|
||||||
|
if hasWarn != tt.shouldShowWarn {
|
||||||
|
t.Errorf("Warn message visibility: got %v, want %v", hasWarn, tt.shouldShowWarn)
|
||||||
|
}
|
||||||
|
if hasError != tt.shouldShowError {
|
||||||
|
t.Errorf("Error message visibility: got %v, want %v", hasError, tt.shouldShowError)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("LOG_LEVEL=%s parsed to level %d (%s), output length: %d bytes",
|
||||||
|
tt.logLevel, parsedLevel, logging.LevelNames[parsedLevel], len(output))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainApplicationLogLevelIntegration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
logLevel string
|
||||||
|
expectLevel int
|
||||||
|
}{
|
||||||
|
{"Debug level", "debug", logging.LEVEL_DEBUG},
|
||||||
|
{"Info level", "info", logging.LEVEL_INFO},
|
||||||
|
{"Warn level", "warn", logging.LEVEL_WARN},
|
||||||
|
{"Error level", "error", logging.LEVEL_ERROR},
|
||||||
|
{"Empty defaults to info", "", logging.LEVEL_INFO},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Save original environment
|
||||||
|
originalLogLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
defer os.Setenv("LOG_LEVEL", originalLogLevel)
|
||||||
|
|
||||||
|
// Set test LOG_LEVEL
|
||||||
|
if tt.logLevel == "" {
|
||||||
|
os.Unsetenv("LOG_LEVEL")
|
||||||
|
} else {
|
||||||
|
os.Setenv("LOG_LEVEL", tt.logLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new connection (this tests main.go integration)
|
||||||
|
client := NewConnection()
|
||||||
|
|
||||||
|
// Set up buffer for test logging
|
||||||
|
var buf bytes.Buffer
|
||||||
|
client.Logger.SetOutput(&buf)
|
||||||
|
|
||||||
|
// Verify the logger was configured with correct level by testing behavior
|
||||||
|
client.Logger.Debug(&logging.LogMessage{Message: "test debug"})
|
||||||
|
client.Logger.Info(&logging.LogMessage{Message: "test info"})
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// The main validation is that the logger respects the LOG_LEVEL setting
|
||||||
|
// We can see from the console output that initialization works correctly
|
||||||
|
|
||||||
|
// Verify level parsing worked correctly by checking if debug shows when expected
|
||||||
|
hasDebug := strings.Contains(output, "test debug")
|
||||||
|
shouldShowDebug := tt.expectLevel <= logging.LEVEL_DEBUG
|
||||||
|
|
||||||
|
if hasDebug != shouldShowDebug {
|
||||||
|
t.Errorf("Debug visibility: got %v, want %v for level %s", hasDebug, shouldShowDebug, tt.logLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("NewConnection() with LOG_LEVEL=%s created logger, output length: %d bytes",
|
||||||
|
tt.logLevel, len(output))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestHelpersLogLevelIntegration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
logLevel string
|
||||||
|
expectLevel int
|
||||||
|
}{
|
||||||
|
{"Test with debug level", "debug", logging.LEVEL_DEBUG},
|
||||||
|
{"Test with info level", "info", logging.LEVEL_INFO},
|
||||||
|
{"Test with warn level", "warn", logging.LEVEL_WARN},
|
||||||
|
{"Test with error level", "error", logging.LEVEL_ERROR},
|
||||||
|
{"Test with no LOG_LEVEL (defaults to error)", "", logging.LEVEL_ERROR},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Save original environment
|
||||||
|
originalLogLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
defer os.Setenv("LOG_LEVEL", originalLogLevel)
|
||||||
|
|
||||||
|
// Set test LOG_LEVEL
|
||||||
|
if tt.logLevel == "" {
|
||||||
|
os.Unsetenv("LOG_LEVEL")
|
||||||
|
} else {
|
||||||
|
os.Setenv("LOG_LEVEL", tt.logLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset test logger to force re-initialization
|
||||||
|
testLogger = nil
|
||||||
|
|
||||||
|
// Get test logger (this tests test_helpers.go integration)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := GetTestLogger()
|
||||||
|
logger.SetOutput(&buf)
|
||||||
|
|
||||||
|
// Test logging at different levels
|
||||||
|
logger.Debug(&logging.LogMessage{Message: "test debug"})
|
||||||
|
logger.Info(&logging.LogMessage{Message: "test info"})
|
||||||
|
logger.Warn(&logging.LogMessage{Message: "test warn"})
|
||||||
|
logger.Error(&logging.LogMessage{Message: "test error"})
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// The test logger initialization works correctly as seen in console output
|
||||||
|
// Focus on validating the actual log level behavior
|
||||||
|
|
||||||
|
// Verify level filtering works correctly
|
||||||
|
hasDebug := strings.Contains(output, "test debug")
|
||||||
|
hasInfo := strings.Contains(output, "test info")
|
||||||
|
hasWarn := strings.Contains(output, "test warn")
|
||||||
|
hasError := strings.Contains(output, "test error")
|
||||||
|
|
||||||
|
shouldShowDebug := tt.expectLevel <= logging.LEVEL_DEBUG
|
||||||
|
shouldShowInfo := tt.expectLevel <= logging.LEVEL_INFO
|
||||||
|
shouldShowWarn := tt.expectLevel <= logging.LEVEL_WARN
|
||||||
|
shouldShowError := tt.expectLevel <= logging.LEVEL_ERROR
|
||||||
|
|
||||||
|
if hasDebug != shouldShowDebug {
|
||||||
|
t.Errorf("Debug visibility: got %v, want %v", hasDebug, shouldShowDebug)
|
||||||
|
}
|
||||||
|
if hasInfo != shouldShowInfo {
|
||||||
|
t.Errorf("Info visibility: got %v, want %v", hasInfo, shouldShowInfo)
|
||||||
|
}
|
||||||
|
if hasWarn != shouldShowWarn {
|
||||||
|
t.Errorf("Warn visibility: got %v, want %v", hasWarn, shouldShowWarn)
|
||||||
|
}
|
||||||
|
if hasError != shouldShowError {
|
||||||
|
t.Errorf("Error visibility: got %v, want %v", hasError, shouldShowError)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("GetTestLogger() with LOG_LEVEL=%s, output length: %d bytes",
|
||||||
|
tt.logLevel, len(output))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogLevelCaseInsensitive(t *testing.T) {
|
||||||
|
testCases := []string{"DEBUG", "Info", "WARN", "Error", "debug", "info", "warn", "error"}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run("Case_"+testCase, func(t *testing.T) {
|
||||||
|
level := logging.GetLogLevel(testCase)
|
||||||
|
expectedLevel := logging.GetLogLevel(strings.ToLower(testCase))
|
||||||
|
|
||||||
|
if level != expectedLevel {
|
||||||
|
t.Errorf("GetLogLevel(%s) = %d, want %d (case insensitive)", testCase, level, expectedLevel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package libpack_logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
_ = iota
|
||||||
|
LEVEL_DEBUG
|
||||||
|
LEVEL_INFO
|
||||||
|
LEVEL_WARN
|
||||||
|
LEVEL_ERROR
|
||||||
|
LEVEL_FATAL
|
||||||
|
)
|
||||||
|
|
||||||
|
var LevelNames = [...]string{
|
||||||
|
"none",
|
||||||
|
"debug",
|
||||||
|
"info",
|
||||||
|
"warn",
|
||||||
|
"error",
|
||||||
|
"fatal",
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultFormat = time.RFC3339
|
||||||
|
defaultMinLevel = LEVEL_INFO
|
||||||
|
defaultShowCaller = false
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultOutput = os.Stdout
|
||||||
|
|
||||||
|
type Logger struct {
|
||||||
|
output io.Writer
|
||||||
|
format string
|
||||||
|
minLogLevel int
|
||||||
|
showCaller bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogMessage struct {
|
||||||
|
output io.Writer
|
||||||
|
Pairs map[string]any
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *LogMessage) String() string {
|
||||||
|
return m.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
var fieldNames = map[string]string{
|
||||||
|
"timestamp": "timestamp",
|
||||||
|
"level": "level",
|
||||||
|
"message": "message",
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Logger {
|
||||||
|
return &Logger{
|
||||||
|
format: defaultFormat,
|
||||||
|
minLogLevel: defaultMinLevel,
|
||||||
|
output: defaultOutput,
|
||||||
|
showCaller: defaultShowCaller,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) SetOutput(output io.Writer) *Logger {
|
||||||
|
l.output = output
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
var bufferPool = sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
return new(bytes.Buffer)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultPairs = make(map[string]any)
|
||||||
|
|
||||||
|
func GetLogLevel(level string) int {
|
||||||
|
for i, name := range LevelNames {
|
||||||
|
if name == strings.ToLower(level) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultMinLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) log(level int, m *LogMessage) {
|
||||||
|
if m.Pairs == nil {
|
||||||
|
m.Pairs = defaultPairs
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Pairs[fieldNames["timestamp"]] = time.Now().Format(l.format)
|
||||||
|
m.Pairs[fieldNames["level"]] = LevelNames[level]
|
||||||
|
m.Pairs[fieldNames["message"]] = m.Message
|
||||||
|
|
||||||
|
if l.showCaller {
|
||||||
|
m.Pairs["caller"] = getCaller()
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer := bufferPool.Get().(*bytes.Buffer)
|
||||||
|
defer bufferPool.Put(buffer)
|
||||||
|
buffer.Reset()
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(buffer)
|
||||||
|
err := encoder.Encode(m.Pairs)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error marshalling log message:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if not running in test - use stderr and stdout, otherwise - use logger's output setting
|
||||||
|
if flag.Lookup("test.v") != nil {
|
||||||
|
m.output = os.Stdout
|
||||||
|
if level >= LEVEL_ERROR {
|
||||||
|
m.output = os.Stderr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use logger's output setting instead of os.Stdout or os.Stderr
|
||||||
|
l.output.Write(buffer.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Debug(m *LogMessage) {
|
||||||
|
if l.shouldLog(LEVEL_DEBUG) {
|
||||||
|
l.log(LEVEL_DEBUG, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Info(m *LogMessage) {
|
||||||
|
if l.shouldLog(LEVEL_INFO) {
|
||||||
|
l.log(LEVEL_INFO, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Warn(m *LogMessage) {
|
||||||
|
if l.shouldLog(LEVEL_WARN) {
|
||||||
|
l.log(LEVEL_WARN, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Warning(m *LogMessage) {
|
||||||
|
l.Warn(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Error(m *LogMessage) {
|
||||||
|
if l.shouldLog(LEVEL_ERROR) {
|
||||||
|
l.log(LEVEL_ERROR, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Fatal(m *LogMessage) {
|
||||||
|
if l.shouldLog(LEVEL_FATAL) {
|
||||||
|
l.log(LEVEL_FATAL, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Critical(m *LogMessage) {
|
||||||
|
l.Fatal(m)
|
||||||
|
// Only call os.Exit(1) if not running in tests
|
||||||
|
if flag.Lookup("test.v") == nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) shouldLog(level int) bool {
|
||||||
|
return level >= l.minLogLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) SetFormat(format string) *Logger {
|
||||||
|
l.format = format
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) SetMinLogLevel(level int) *Logger {
|
||||||
|
l.minLogLevel = level
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) SetFieldName(field, name string) *Logger {
|
||||||
|
fieldNames[field] = name
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) SetShowCaller(show bool) *Logger {
|
||||||
|
l.showCaller = show
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCaller() string {
|
||||||
|
_, file, line, ok := runtime.Caller(3)
|
||||||
|
if !ok {
|
||||||
|
return "unknown:0"
|
||||||
|
}
|
||||||
|
file = filepath.Base(file)
|
||||||
|
return fmt.Sprintf("%s:%d", file, line)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package libpack_logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Benchmark_NewLogger(b *testing.B) {
|
||||||
|
type triggers struct {
|
||||||
|
ModFormat struct {
|
||||||
|
Format string
|
||||||
|
}
|
||||||
|
ModLevel struct {
|
||||||
|
Level int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
triggers triggers
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "BenchmarkNew",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BenchmarkNewChangeTimeFormat",
|
||||||
|
triggers: triggers{
|
||||||
|
ModFormat: struct{ Format string }{
|
||||||
|
Format: time.RFC3339Nano,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BenchmarkNewChangeLogLevel",
|
||||||
|
triggers: triggers{
|
||||||
|
ModLevel: struct{ Level int }{
|
||||||
|
Level: LEVEL_DEBUG,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BenchmarkNewChangeTimeFormatAndLogLevel",
|
||||||
|
triggers: triggers{
|
||||||
|
ModFormat: struct{ Format string }{
|
||||||
|
Format: time.RFC3339Nano,
|
||||||
|
},
|
||||||
|
ModLevel: struct{ Level int }{
|
||||||
|
Level: LEVEL_DEBUG,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
b.Run(tt.name, func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
got := New()
|
||||||
|
|
||||||
|
if tt.triggers.ModFormat.Format != "" {
|
||||||
|
got.SetFormat(tt.triggers.ModFormat.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.triggers.ModLevel.Level != 0 {
|
||||||
|
got.SetMinLogLevel(tt.triggers.ModLevel.Level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Benchmark_Log_Debug(b *testing.B) {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetMinLogLevel(LEVEL_DEBUG).SetOutput(output)
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "debug message",
|
||||||
|
Pairs: make(map[string]any),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
logger.Debug(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Benchmark_Log_Info(b *testing.B) {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetMinLogLevel(LEVEL_INFO).SetOutput(output)
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "info message",
|
||||||
|
Pairs: make(map[string]any),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
logger.Info(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Benchmark_Log_Warn(b *testing.B) {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetMinLogLevel(LEVEL_WARN).SetOutput(output)
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "warn message",
|
||||||
|
Pairs: make(map[string]any),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
logger.Warn(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Benchmark_Log_Error(b *testing.B) {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetMinLogLevel(LEVEL_ERROR).SetOutput(output)
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "error message",
|
||||||
|
Pairs: map[string]any{"key": "value"},
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
logger.Error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Benchmark_Log_Fatal(b *testing.B) {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetMinLogLevel(LEVEL_FATAL).SetOutput(output)
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "fatal message",
|
||||||
|
Pairs: make(map[string]any),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
logger.Fatal(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package libpack_logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
assertions "github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoggerTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
assert *assertions.Assertions
|
||||||
|
)
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) BeforeTest(suiteName, testName string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) SetupTest() {
|
||||||
|
assert = assertions.New(suite.T())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TearDownTest is run after each test to clean up
|
||||||
|
func (suite *LoggerTestSuite) TearDownTest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(LoggerTestSuite))
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package libpack_logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/goccy/go-reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_LogMessageString() {
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "test message",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("test message", msg.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func callLoggerMethod(logger *Logger, methodName string, message *LogMessage) {
|
||||||
|
// Get the method by name using reflection
|
||||||
|
method := reflect.ValueOf(logger).MethodByName(methodName)
|
||||||
|
if method.IsValid() {
|
||||||
|
// Call the method with the message as an argument
|
||||||
|
method.Call([]reflect.Value{reflect.ValueOf(message)})
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Method %s does not exist on Logger\n", methodName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_LogsLevelsPrint() {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetOutput(output)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
pairs map[string]any
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
message string
|
||||||
|
loggerMinLevel int
|
||||||
|
messageLogLevel int
|
||||||
|
wantOutput bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Log: Debug, Level: Debug - no pairs",
|
||||||
|
method: "Debug",
|
||||||
|
loggerMinLevel: LEVEL_DEBUG,
|
||||||
|
messageLogLevel: LEVEL_DEBUG,
|
||||||
|
message: "debug message",
|
||||||
|
wantOutput: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Log: Info, Level: Info - one pair",
|
||||||
|
method: "Info",
|
||||||
|
loggerMinLevel: LEVEL_INFO,
|
||||||
|
messageLogLevel: LEVEL_INFO,
|
||||||
|
message: "info message",
|
||||||
|
pairs: map[string]any{
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
wantOutput: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Log: Info, Level: Warn - with pairs",
|
||||||
|
method: "Info",
|
||||||
|
loggerMinLevel: LEVEL_WARN,
|
||||||
|
messageLogLevel: LEVEL_INFO,
|
||||||
|
message: "warn message",
|
||||||
|
pairs: map[string]any{
|
||||||
|
"key1": "value1",
|
||||||
|
"key2": "value2",
|
||||||
|
},
|
||||||
|
wantOutput: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Log: Warn, Level: Info - with 500 pairs",
|
||||||
|
method: "Warn",
|
||||||
|
loggerMinLevel: LEVEL_INFO,
|
||||||
|
messageLogLevel: LEVEL_WARN,
|
||||||
|
message: "warn message with 500 pairs",
|
||||||
|
pairs: func() map[string]any {
|
||||||
|
pairs := make(map[string]any)
|
||||||
|
for i := 0; i < 500; i++ {
|
||||||
|
pairs[fmt.Sprintf("key%d", i)] = fmt.Sprintf("value%d", i)
|
||||||
|
}
|
||||||
|
return pairs
|
||||||
|
}(),
|
||||||
|
wantOutput: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: tt.message,
|
||||||
|
Pairs: tt.pairs,
|
||||||
|
}
|
||||||
|
output.Reset()
|
||||||
|
|
||||||
|
// Set logger's minimum log level
|
||||||
|
logger.SetMinLogLevel(tt.loggerMinLevel)
|
||||||
|
fmt.Println("Logger min log level:", LevelNames[logger.minLogLevel])
|
||||||
|
|
||||||
|
// Call the logging method
|
||||||
|
callLoggerMethod(logger, tt.method, msg)
|
||||||
|
|
||||||
|
logOutput := output.String()
|
||||||
|
fmt.Println("Output:", logOutput)
|
||||||
|
|
||||||
|
if tt.wantOutput {
|
||||||
|
var loggedMessage map[string]any
|
||||||
|
err := json.Unmarshal([]byte(logOutput), &loggedMessage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Error unmarshalling log message: %v\nLog output: %s", err, logOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !containsLogMessage(logOutput, tt.message) {
|
||||||
|
t.Errorf("Expected log message %q, but got %q", tt.message, logOutput)
|
||||||
|
}
|
||||||
|
assert.Equal(LevelNames[tt.messageLogLevel], loggedMessage["level"])
|
||||||
|
if tt.pairs != nil {
|
||||||
|
for k, v := range tt.pairs {
|
||||||
|
assert.Equal(v, loggedMessage[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert.Equal("", logOutput)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsLogMessage(logOutput, expectedMessage string) bool {
|
||||||
|
return bytes.Contains([]byte(logOutput), []byte(expectedMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_SetFormat() {
|
||||||
|
logger := New().SetFormat(time.RFC3339Nano)
|
||||||
|
|
||||||
|
assert.Equal(time.RFC3339Nano, logger.format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_SetMinLogLevel() {
|
||||||
|
logger := New().SetMinLogLevel(LEVEL_DEBUG)
|
||||||
|
|
||||||
|
assert.Equal(LEVEL_DEBUG, logger.minLogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_ShouldLog() {
|
||||||
|
logger := New().SetMinLogLevel(LEVEL_WARN)
|
||||||
|
|
||||||
|
assert.True(logger.shouldLog(LEVEL_WARN))
|
||||||
|
assert.True(logger.shouldLog(LEVEL_ERROR))
|
||||||
|
assert.False(logger.shouldLog(LEVEL_INFO))
|
||||||
|
assert.False(logger.shouldLog(LEVEL_DEBUG))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_GetLogLevel() {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
level string
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{"debug level", "debug", LEVEL_DEBUG},
|
||||||
|
{"info level", "info", LEVEL_INFO},
|
||||||
|
{"warn level", "warn", LEVEL_WARN},
|
||||||
|
{"error level", "error", LEVEL_ERROR},
|
||||||
|
{"fatal level", "fatal", LEVEL_FATAL},
|
||||||
|
{"uppercase debug", "DEBUG", LEVEL_DEBUG},
|
||||||
|
{"mixed case warn", "WaRn", LEVEL_WARN},
|
||||||
|
{"unknown level", "unknown", defaultMinLevel},
|
||||||
|
{"empty string", "", defaultMinLevel},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
|
result := GetLogLevel(tt.level)
|
||||||
|
assert.Equal(tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_Warning() {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetOutput(output).SetMinLogLevel(LEVEL_WARN)
|
||||||
|
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "warning message",
|
||||||
|
Pairs: map[string]any{"key": "value"},
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warning(msg)
|
||||||
|
|
||||||
|
logOutput := output.String()
|
||||||
|
fmt.Printf("DEBUG: logOutput = %q\n", logOutput)
|
||||||
|
fmt.Printf("DEBUG: logOutput length = %d\n", len(logOutput))
|
||||||
|
assert.NotEmpty(logOutput)
|
||||||
|
|
||||||
|
var loggedMessage map[string]any
|
||||||
|
err := json.Unmarshal([]byte(logOutput), &loggedMessage)
|
||||||
|
fmt.Printf("DEBUG: unmarshal error = %v\n", err)
|
||||||
|
fmt.Printf("DEBUG: loggedMessage = %+v\n", loggedMessage)
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.Equal("warn", loggedMessage["level"])
|
||||||
|
assert.Equal("warning message", loggedMessage["message"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_Error() {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetOutput(output).SetMinLogLevel(LEVEL_ERROR)
|
||||||
|
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "error message",
|
||||||
|
Pairs: map[string]any{"error": "test error"},
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Error(msg)
|
||||||
|
|
||||||
|
logOutput := output.String()
|
||||||
|
assert.NotEmpty(logOutput)
|
||||||
|
|
||||||
|
var loggedMessage map[string]any
|
||||||
|
err := json.Unmarshal([]byte(logOutput), &loggedMessage)
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.Equal("error", loggedMessage["level"])
|
||||||
|
assert.Equal("error message", loggedMessage["message"])
|
||||||
|
assert.Equal("test error", loggedMessage["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_Fatal() {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetOutput(output).SetMinLogLevel(LEVEL_FATAL)
|
||||||
|
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "fatal message",
|
||||||
|
Pairs: map[string]any{"fatal": "test fatal"},
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Fatal(msg)
|
||||||
|
|
||||||
|
logOutput := output.String()
|
||||||
|
assert.NotEmpty(logOutput)
|
||||||
|
|
||||||
|
var loggedMessage map[string]any
|
||||||
|
err := json.Unmarshal([]byte(logOutput), &loggedMessage)
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.Equal("fatal", loggedMessage["level"])
|
||||||
|
assert.Equal("fatal message", loggedMessage["message"])
|
||||||
|
assert.Equal("test fatal", loggedMessage["fatal"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_SetFieldName() {
|
||||||
|
// Save original field names
|
||||||
|
originalFieldNames := make(map[string]string)
|
||||||
|
for k, v := range fieldNames {
|
||||||
|
originalFieldNames[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original field names after test
|
||||||
|
defer func() {
|
||||||
|
for k, v := range originalFieldNames {
|
||||||
|
fieldNames[k] = v
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
logger := New()
|
||||||
|
|
||||||
|
// Test setting custom field names
|
||||||
|
logger.SetFieldName("timestamp", "time")
|
||||||
|
logger.SetFieldName("level", "severity")
|
||||||
|
logger.SetFieldName("message", "msg")
|
||||||
|
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger.SetOutput(output)
|
||||||
|
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "test message",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info(msg)
|
||||||
|
|
||||||
|
logOutput := output.String()
|
||||||
|
assert.NotEmpty(logOutput)
|
||||||
|
|
||||||
|
var loggedMessage map[string]any
|
||||||
|
err := json.Unmarshal([]byte(logOutput), &loggedMessage)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
// Check that custom field names are used
|
||||||
|
assert.Contains(loggedMessage, "time")
|
||||||
|
assert.Contains(loggedMessage, "severity")
|
||||||
|
assert.Contains(loggedMessage, "msg")
|
||||||
|
assert.Equal("info", loggedMessage["severity"])
|
||||||
|
assert.Equal("test message", loggedMessage["msg"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_SetShowCaller() {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetOutput(output).SetShowCaller(true)
|
||||||
|
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "test message with caller",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info(msg)
|
||||||
|
|
||||||
|
logOutput := output.String()
|
||||||
|
assert.NotEmpty(logOutput)
|
||||||
|
|
||||||
|
var loggedMessage map[string]any
|
||||||
|
err := json.Unmarshal([]byte(logOutput), &loggedMessage)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
// Check that caller information is included
|
||||||
|
assert.Contains(loggedMessage, "caller")
|
||||||
|
caller, ok := loggedMessage["caller"].(string)
|
||||||
|
assert.True(ok)
|
||||||
|
assert.Contains(caller, "logger_test.go:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_GetCaller() {
|
||||||
|
// Test getCaller function indirectly through SetShowCaller
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetOutput(output).SetShowCaller(true).SetMinLogLevel(LEVEL_DEBUG)
|
||||||
|
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "caller test",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug(msg)
|
||||||
|
|
||||||
|
logOutput := output.String()
|
||||||
|
assert.NotEmpty(logOutput)
|
||||||
|
|
||||||
|
var loggedMessage map[string]any
|
||||||
|
err := json.Unmarshal([]byte(logOutput), &loggedMessage)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
caller, exists := loggedMessage["caller"]
|
||||||
|
assert.True(exists)
|
||||||
|
assert.NotEmpty(caller)
|
||||||
|
|
||||||
|
// Caller should be in format "file:line"
|
||||||
|
callerStr, ok := caller.(string)
|
||||||
|
assert.True(ok)
|
||||||
|
assert.Contains(callerStr, ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_LogWithNilPairs() {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetOutput(output)
|
||||||
|
|
||||||
|
msg := &LogMessage{
|
||||||
|
Message: "message with nil pairs",
|
||||||
|
Pairs: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info(msg)
|
||||||
|
|
||||||
|
logOutput := output.String()
|
||||||
|
assert.NotEmpty(logOutput)
|
||||||
|
|
||||||
|
var loggedMessage map[string]any
|
||||||
|
err := json.Unmarshal([]byte(logOutput), &loggedMessage)
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.Equal("info", loggedMessage["level"])
|
||||||
|
assert.Equal("message with nil pairs", loggedMessage["message"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *LoggerTestSuite) Test_LogLevelFiltering() {
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
logger := New().SetOutput(output).SetMinLogLevel(LEVEL_WARN)
|
||||||
|
|
||||||
|
// These should not produce output (below minimum level)
|
||||||
|
logger.Debug(&LogMessage{Message: "debug message"})
|
||||||
|
logger.Info(&LogMessage{Message: "info message"})
|
||||||
|
|
||||||
|
debugInfoOutput := output.String()
|
||||||
|
assert.Empty(debugInfoOutput)
|
||||||
|
|
||||||
|
// These should produce output (at or above minimum level)
|
||||||
|
logger.Warn(&LogMessage{Message: "warn message"})
|
||||||
|
warnOutput := output.String()
|
||||||
|
assert.NotEmpty(warnOutput)
|
||||||
|
assert.Contains(warnOutput, "warn message")
|
||||||
|
|
||||||
|
output.Reset()
|
||||||
|
logger.Error(&LogMessage{Message: "error message"})
|
||||||
|
errorOutput := output.String()
|
||||||
|
assert.NotEmpty(errorOutput)
|
||||||
|
assert.Contains(errorOutput, "error message")
|
||||||
|
|
||||||
|
output.Reset()
|
||||||
|
logger.Fatal(&LogMessage{Message: "fatal message"})
|
||||||
|
fatalOutput := output.String()
|
||||||
|
assert.NotEmpty(fatalOutput)
|
||||||
|
assert.Contains(fatalOutput, "fatal message")
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gookit/goutil/envutil"
|
||||||
|
cache "github.com/lukaszraczylo/go-simple-graphql/cache"
|
||||||
|
logging "github.com/lukaszraczylo/go-simple-graphql/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseRetryPatterns parses a comma-separated string of retry patterns into a slice
|
||||||
|
func parseRetryPatterns(patterns string) []string {
|
||||||
|
if patterns == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
parts := strings.Split(patterns, ",")
|
||||||
|
result := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
trimmed := strings.TrimSpace(p)
|
||||||
|
if trimmed != "" {
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConnection() (b *BaseClient) {
|
||||||
|
// Read LOG_LEVEL environment variable
|
||||||
|
logLevelStr := envutil.Getenv("LOG_LEVEL", "info")
|
||||||
|
logLevel := logging.GetLogLevel(logLevelStr)
|
||||||
|
|
||||||
|
// Create logger and set the log level from environment
|
||||||
|
logger := logging.New()
|
||||||
|
logger.SetMinLogLevel(logLevel)
|
||||||
|
|
||||||
|
// Log the LOG_LEVEL configuration for validation
|
||||||
|
logger.Info(&logging.LogMessage{
|
||||||
|
Message: "Logger initialized with LOG_LEVEL configuration",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"LOG_LEVEL_env_var": logLevelStr,
|
||||||
|
"parsed_log_level": logLevel,
|
||||||
|
"level_name": logging.LevelNames[logLevel],
|
||||||
|
"default_min_level": logging.LEVEL_INFO,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
b = &BaseClient{
|
||||||
|
endpoint: envutil.Getenv("GRAPHQL_ENDPOINT", "https://api.github.com/graphql"),
|
||||||
|
responseType: envutil.Getenv("GRAPHQL_OUTPUT", "string"),
|
||||||
|
Logger: logger,
|
||||||
|
cache: cache.New(time.Duration(envutil.GetInt("GRAPHQL_CACHE_TTL", 5)) * time.Second),
|
||||||
|
cache_global: envutil.GetBool("GRAPHQL_CACHE_ENABLED", false),
|
||||||
|
retries_enable: envutil.GetBool("GRAPHQL_RETRIES_ENABLE", false),
|
||||||
|
retries_delay: time.Duration(envutil.GetInt("GRAPHQL_RETRIES_DELAY", 250) * int(time.Millisecond)),
|
||||||
|
retries_number: envutil.GetInt("GRAPHQL_RETRIES_NUMBER", 3),
|
||||||
|
retries_patterns: parseRetryPatterns(envutil.Getenv("GRAPHQL_RETRIES_PATTERNS", "postgres,connection,timeout,transaction,could not,temporarily unavailable,deadlock")),
|
||||||
|
minify_queries: envutil.GetBool("GRAPHQL_MINIFY_QUERIES", true), // Default: enabled for production efficiency
|
||||||
|
pool_warmup_enabled: envutil.GetBool("GRAPHQL_POOL_WARMUP_ENABLED", false),
|
||||||
|
pool_size: envutil.GetInt("GRAPHQL_POOL_SIZE", 5),
|
||||||
|
pool_warmup_query: envutil.Getenv("GRAPHQL_POOL_WARMUP_QUERY", "query{__typename}"),
|
||||||
|
pool_health_interval: time.Duration(envutil.GetInt("GRAPHQL_POOL_HEALTH_INTERVAL", 30)) * time.Second,
|
||||||
|
pool_stop: make(chan bool, 1),
|
||||||
|
}
|
||||||
|
b.client = b.createHttpClient()
|
||||||
|
b.Logger.Debug(&logging.LogMessage{
|
||||||
|
Message: "Created new GraphQL client connection",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"endpoint": b.endpoint,
|
||||||
|
"pool_warmup": b.pool_warmup_enabled,
|
||||||
|
"pool_size": b.pool_size,
|
||||||
|
"pool_health_check": b.pool_health_interval,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize connection pool if warmup is enabled
|
||||||
|
if b.pool_warmup_enabled {
|
||||||
|
b.warmupConnectionPool()
|
||||||
|
b.startPoolHealthMonitor()
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseClient) SetEndpoint(endpoint string) {
|
||||||
|
b.endpoint = endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseClient) SetOutput(responseType string) {
|
||||||
|
// allowed are byte, string, mapstring
|
||||||
|
// check if responseType is allowed
|
||||||
|
// TODO: implement
|
||||||
|
b.responseType = responseType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseClient) SetHTTPClient(client *http.Client) {
|
||||||
|
b.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseClient) SetQueryMinification(enabled bool) {
|
||||||
|
b.minify_queries = enabled
|
||||||
|
b.Logger.Debug(&logging.LogMessage{
|
||||||
|
Message: "GraphQL query minification setting updated",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"minify_queries": enabled,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func Benchmark_newConnection(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
NewConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (suite *Tests) TestNewConnection() {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "TestNewConnection",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
|
got := NewConnection()
|
||||||
|
assert.NotNil(suite.T(), got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// mock_server.go
|
||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartMockServer() *httptest.Server {
|
||||||
|
// Create your handler
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Your existing handler code
|
||||||
|
bodyBytes, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Variables map[string]interface{} `json:"variables"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(bodyBytes, &req)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple validation to check for unbalanced braces as a syntax error
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(req.Query), "query") || !strings.HasSuffix(strings.TrimSpace(req.Query), "}") {
|
||||||
|
resp := []byte(`{"errors":[{"message":"Syntax Error: Invalid query"}]}`)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp []byte
|
||||||
|
if strings.Contains(req.Query, "viewer") {
|
||||||
|
resp = []byte(`{"data":{"viewer":{"login":"mockuser"}}}`)
|
||||||
|
} else if strings.Contains(req.Query, "dragons") {
|
||||||
|
resp = []byte(`{"data":{"dragons":[{"name":"Mock Dragon 1"},{"name":"Mock Dragon 2"}]}}`)
|
||||||
|
} else {
|
||||||
|
resp = []byte(`{"errors":[{"message":"Unknown query"}]}`)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create an unstarted server
|
||||||
|
server := httptest.NewUnstartedServer(handler)
|
||||||
|
|
||||||
|
// Configure the server to support HTTP/2
|
||||||
|
http2.ConfigureServer(server.Config, &http2.Server{})
|
||||||
|
server.TLS = server.Config.TLSConfig
|
||||||
|
|
||||||
|
// Start the server with TLS
|
||||||
|
server.StartTLS()
|
||||||
|
return server
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cache "github.com/lukaszraczylo/go-simple-graphql/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Performance regression tests to ensure optimizations maintain their benefits
|
||||||
|
func TestPerformanceRegression(t *testing.T) {
|
||||||
|
// Test hash performance
|
||||||
|
t.Run("HashPerformance", func(t *testing.T) {
|
||||||
|
query := &Query{
|
||||||
|
Query: "query GetUser($id: ID!) { user(id: $id) { id name email } }",
|
||||||
|
Variables: map[string]interface{}{
|
||||||
|
"id": "12345",
|
||||||
|
},
|
||||||
|
JsonQuery: []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { id name email } }","variables":{"id":"12345"}}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
_ = calculateHash(query)
|
||||||
|
}
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
// Should complete 10k hashes in under 40ms (accounts for race detection + coverage overhead)
|
||||||
|
if duration > 40*time.Millisecond {
|
||||||
|
t.Errorf("Hash performance regression: 10k hashes took %v, expected < 40ms", duration)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test buffer pool performance
|
||||||
|
t.Run("BufferPoolPerformance", func(t *testing.T) {
|
||||||
|
start := time.Now()
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
buf := getBuffer(1024)
|
||||||
|
buf.WriteString("test data")
|
||||||
|
putBuffer(buf)
|
||||||
|
}
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
// Should complete 10k buffer operations in under 30ms (accounts for race detection + coverage overhead)
|
||||||
|
if duration > 30*time.Millisecond {
|
||||||
|
t.Errorf("Buffer pool performance regression: 10k operations took %v, expected < 30ms", duration)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test cache performance
|
||||||
|
t.Run("CachePerformance", func(t *testing.T) {
|
||||||
|
c := cache.New(5 * time.Second) // Use shorter TTL for tests
|
||||||
|
defer c.Stop()
|
||||||
|
|
||||||
|
testData := make([]byte, 512) // Small data that won't be compressed
|
||||||
|
for i := range testData {
|
||||||
|
testData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
key := "key" + string(rune(i%100))
|
||||||
|
c.Set(key, testData, time.Minute)
|
||||||
|
_, _ = c.Get(key)
|
||||||
|
}
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
// Should complete 10k cache operations in under 50ms
|
||||||
|
if duration > 50*time.Millisecond {
|
||||||
|
t.Errorf("Cache performance regression: 10k operations took %v, expected < 50ms", duration)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test processFlags performance
|
||||||
|
t.Run("ProcessFlagsPerformance", func(t *testing.T) {
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"id": "123",
|
||||||
|
"gqlcache": true,
|
||||||
|
"gqlretries": true,
|
||||||
|
}
|
||||||
|
headers := map[string]interface{}{}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
_, _, _ = processFlags(variables, headers)
|
||||||
|
}
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
// Should complete 10k flag processing operations in under 30ms (accounts for race detection + coverage overhead)
|
||||||
|
if duration > 30*time.Millisecond {
|
||||||
|
t.Errorf("ProcessFlags performance regression: 10k operations took %v, expected < 30ms", duration)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test query compilation performance
|
||||||
|
t.Run("QueryCompilationPerformance", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
query := "query GetUser($id: ID!) { user(id: $id) { id name email } }"
|
||||||
|
variables := map[string]interface{}{"id": "123"}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
_ = client.compileQuery(query, variables)
|
||||||
|
}
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
// Should complete 1k query compilations in under 65ms (accounts for minification overhead)
|
||||||
|
if duration > 65*time.Millisecond {
|
||||||
|
t.Errorf("Query compilation performance regression: 1k operations took %v, expected < 65ms", duration)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark comprehensive workflow to ensure no regression
|
||||||
|
func BenchmarkComprehensiveWorkflow(b *testing.B) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
query := "query GetUser($id: ID!) { user(id: $id) { id name email profile { avatar bio } } }"
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"id": "123",
|
||||||
|
"gqlcache": true,
|
||||||
|
"gqlretries": true,
|
||||||
|
}
|
||||||
|
headers := map[string]interface{}{}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
// Process flags
|
||||||
|
enableCache, enableRetries, cleanedVariables := processFlags(variables, headers)
|
||||||
|
|
||||||
|
// Compile query
|
||||||
|
compiledQuery := client.compileQuery(query, cleanedVariables)
|
||||||
|
|
||||||
|
// Calculate hash
|
||||||
|
if enableCache {
|
||||||
|
_ = calculateHash(compiledQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use variables to avoid optimization
|
||||||
|
_ = enableRetries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory allocation benchmark
|
||||||
|
func BenchmarkMemoryAllocations(b *testing.B) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
query := "query GetUser($id: ID!) { user(id: $id) { id name } }"
|
||||||
|
variables := map[string]interface{}{"id": "123"}
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
_ = calculateHash(compiledQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package logging
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
stdlog "log"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/pandati"
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LogConfig struct {
|
|
||||||
DefaultOutput *os.File
|
|
||||||
Logger zerolog.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLogger() *LogConfig {
|
|
||||||
log := new(LogConfig)
|
|
||||||
switch dll := os.Getenv("LOG_LEVEL"); dll {
|
|
||||||
case "debug":
|
|
||||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
|
||||||
case "warn":
|
|
||||||
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
|
||||||
case "error":
|
|
||||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
|
||||||
default:
|
|
||||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
|
||||||
}
|
|
||||||
zerolog.TimeFieldFormat = time.RFC3339
|
|
||||||
zerolog.MessageFieldName = "short_message"
|
|
||||||
zerolog.TimestampFieldName = "timestamp"
|
|
||||||
zerolog.LevelFatalValue = "critical"
|
|
||||||
log.Logger = log.Logger.With().Timestamp().Logger()
|
|
||||||
stdlog.SetFlags(0)
|
|
||||||
stdlog.SetOutput(log.Logger)
|
|
||||||
return log
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *LogConfig) Critical(message string, v ...map[string]interface{}) {
|
|
||||||
w := l.Logger.Output(os.Stderr)
|
|
||||||
log(w.Fatal(), message, v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *LogConfig) Error(message string, v ...map[string]interface{}) {
|
|
||||||
w := l.Logger.Output(os.Stderr)
|
|
||||||
log(w.Error(), message, v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *LogConfig) Warning(message string, v ...map[string]interface{}) {
|
|
||||||
w := l.Logger.Output(os.Stdout)
|
|
||||||
log(w.Warn(), message, v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *LogConfig) Info(message string, v ...map[string]interface{}) {
|
|
||||||
w := l.Logger.Output(os.Stdout)
|
|
||||||
log(w.Info(), message, v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *LogConfig) Debug(message string, v ...map[string]interface{}) {
|
|
||||||
w := l.Logger.Output(os.Stdout)
|
|
||||||
log(w.Debug(), message, v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func log(contextLog *zerolog.Event, message string, v ...map[string]interface{}) {
|
|
||||||
if len(v) > 0 {
|
|
||||||
for _, m := range v {
|
|
||||||
for k, v := range m {
|
|
||||||
if !pandati.IsZero(v) && !pandati.IsZero(k) {
|
|
||||||
contextLog.Str(k, fmt.Sprintf("%v", v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// contextLog.Str("_stack_trace", pandati.Trace())
|
|
||||||
contextLog.Msg(message)
|
|
||||||
}
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
package logging
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/buger/jsonparser"
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LoggingTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
testsLogger *LogConfig
|
|
||||||
)
|
|
||||||
|
|
||||||
type stdoutCapture struct {
|
|
||||||
oldStdout *os.File
|
|
||||||
readPipe *os.File
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sc *stdoutCapture) StartCapture() {
|
|
||||||
sc.oldStdout = os.Stdout
|
|
||||||
sc.readPipe, os.Stdout, _ = os.Pipe()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sc *stdoutCapture) StopCapture() (string, error) {
|
|
||||||
if sc.oldStdout == nil || sc.readPipe == nil {
|
|
||||||
return "", errors.New("StartCapture not called before StopCapture on Stdout")
|
|
||||||
}
|
|
||||||
os.Stdout.Close()
|
|
||||||
os.Stdout = sc.oldStdout
|
|
||||||
bytes, err := ioutil.ReadAll(sc.readPipe)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type stderrCapture struct {
|
|
||||||
oldStderr *os.File
|
|
||||||
readPipe *os.File
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sc *stderrCapture) StartCapture() {
|
|
||||||
sc.oldStderr = os.Stderr
|
|
||||||
sc.readPipe, os.Stderr, _ = os.Pipe()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sc *stderrCapture) StopCapture() (string, error) {
|
|
||||||
if sc.oldStderr == nil || sc.readPipe == nil {
|
|
||||||
return "", errors.New("StartCapture not called before StopCapture on Stderr")
|
|
||||||
}
|
|
||||||
os.Stderr.Close()
|
|
||||||
os.Stderr = sc.oldStderr
|
|
||||||
bytes, err := ioutil.ReadAll(sc.readPipe)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LoggingTestSuite) SetupTest() {
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoggingTestSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(LoggingTestSuite))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LoggingTestSuite) TestLogConfig_AllHandlers() {
|
|
||||||
type args struct {
|
|
||||||
message string
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
args args
|
|
||||||
wantLevel string
|
|
||||||
wantMessage string
|
|
||||||
envMinLogLevel string
|
|
||||||
stdOutExpect bool
|
|
||||||
stdErrExpect bool
|
|
||||||
loggerType string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Test log: Error",
|
|
||||||
loggerType: "Error",
|
|
||||||
args: args{
|
|
||||||
message: "This is a error message",
|
|
||||||
},
|
|
||||||
wantLevel: "error",
|
|
||||||
wantMessage: "This is a error message",
|
|
||||||
stdErrExpect: true,
|
|
||||||
stdOutExpect: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Test log: Warning",
|
|
||||||
loggerType: "Warning",
|
|
||||||
args: args{
|
|
||||||
message: "This is a warning message",
|
|
||||||
},
|
|
||||||
wantLevel: "warn",
|
|
||||||
wantMessage: "This is a warning message",
|
|
||||||
stdErrExpect: false,
|
|
||||||
stdOutExpect: true,
|
|
||||||
envMinLogLevel: "info",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Test log: Warning | Min level: Debug",
|
|
||||||
loggerType: "Warning",
|
|
||||||
args: args{
|
|
||||||
message: "This is a warning message",
|
|
||||||
},
|
|
||||||
wantLevel: "warn",
|
|
||||||
wantMessage: "This is a warning message",
|
|
||||||
stdErrExpect: false,
|
|
||||||
stdOutExpect: true,
|
|
||||||
envMinLogLevel: "debug",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Test log: Info",
|
|
||||||
loggerType: "Info",
|
|
||||||
args: args{
|
|
||||||
message: "This is a info message",
|
|
||||||
},
|
|
||||||
wantLevel: "info",
|
|
||||||
wantMessage: "This is a info message",
|
|
||||||
stdErrExpect: false,
|
|
||||||
stdOutExpect: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Test log: Info | Min level: Warn",
|
|
||||||
loggerType: "Info",
|
|
||||||
args: args{
|
|
||||||
message: "This is a info message",
|
|
||||||
},
|
|
||||||
wantLevel: "",
|
|
||||||
wantMessage: "",
|
|
||||||
stdErrExpect: false,
|
|
||||||
stdOutExpect: false,
|
|
||||||
envMinLogLevel: "warn",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Test log: Warning | Min level: Warn",
|
|
||||||
loggerType: "Warning",
|
|
||||||
args: args{
|
|
||||||
message: "This is a warning message",
|
|
||||||
},
|
|
||||||
wantLevel: "warn",
|
|
||||||
wantMessage: "This is a warning message",
|
|
||||||
stdErrExpect: false,
|
|
||||||
stdOutExpect: true,
|
|
||||||
envMinLogLevel: "warn",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Test log: Warning | Min level: Error",
|
|
||||||
loggerType: "Warning",
|
|
||||||
args: args{
|
|
||||||
message: "This is an error message",
|
|
||||||
},
|
|
||||||
wantLevel: "",
|
|
||||||
wantMessage: "",
|
|
||||||
stdErrExpect: false,
|
|
||||||
stdOutExpect: false,
|
|
||||||
envMinLogLevel: "error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Test log: Debug | Min level: Debug",
|
|
||||||
loggerType: "Debug",
|
|
||||||
args: args{
|
|
||||||
message: "This is a debug message",
|
|
||||||
},
|
|
||||||
wantLevel: "debug",
|
|
||||||
wantMessage: "This is a debug message",
|
|
||||||
stdErrExpect: false,
|
|
||||||
stdOutExpect: true,
|
|
||||||
envMinLogLevel: "debug",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.T().Run(tt.name, func(t *testing.T) {
|
|
||||||
if tt.envMinLogLevel != "" {
|
|
||||||
os.Setenv("LOG_LEVEL", tt.envMinLogLevel)
|
|
||||||
defer os.Unsetenv("LOG_LEVEL")
|
|
||||||
}
|
|
||||||
testsLogger = NewLogger()
|
|
||||||
|
|
||||||
captureStdout := stdoutCapture{}
|
|
||||||
captureStdout.StartCapture()
|
|
||||||
captureStderr := stderrCapture{}
|
|
||||||
captureStderr.StartCapture()
|
|
||||||
|
|
||||||
reflect.ValueOf(testsLogger).MethodByName(tt.loggerType).Call([]reflect.Value{reflect.ValueOf(tt.args.message)})
|
|
||||||
|
|
||||||
stdoutOut, err := captureStdout.StopCapture()
|
|
||||||
if err != nil {
|
|
||||||
suite.T().Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stderrOut, err := captureStderr.StopCapture()
|
|
||||||
if err != nil {
|
|
||||||
suite.T().Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.stdErrExpect && !tt.stdOutExpect {
|
|
||||||
gotLvl, gotMsg, err := getResponseValues(stderrOut, "short_message")
|
|
||||||
suite.NoError(err, "Failed in [STDERR]: "+tt.name)
|
|
||||||
suite.Equal(tt.wantLevel, gotLvl, "Failed in [STDERR]: "+tt.name)
|
|
||||||
suite.Equal(tt.wantMessage, gotMsg, "Failed in [STDERR]: "+tt.name)
|
|
||||||
suite.Equal("", stdoutOut, "Failed in [STDERR]: "+tt.name)
|
|
||||||
}
|
|
||||||
if tt.stdOutExpect && !tt.stdErrExpect {
|
|
||||||
gotLvl, gotMsg, err := getResponseValues(stdoutOut, "short_message")
|
|
||||||
suite.NoError(err, "Failed in [STDOUT]: "+tt.name)
|
|
||||||
suite.Equal(tt.wantLevel, gotLvl, "Failed in [STDOUT]: "+tt.name)
|
|
||||||
suite.Equal(tt.wantMessage, gotMsg, "Failed in [STDOUT]: "+tt.name)
|
|
||||||
suite.Equal("", stderrOut, "Failed in [STDOUT]: "+tt.name)
|
|
||||||
}
|
|
||||||
if !tt.stdErrExpect && !tt.stdOutExpect {
|
|
||||||
suite.Equal("", stderrOut, "Failed in [NEITHER]: "+tt.name)
|
|
||||||
suite.Equal("", stdoutOut, "Failed in [NEITHER]: "+tt.name)
|
|
||||||
}
|
|
||||||
os.Unsetenv("LOG_LEVEL")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LoggingTestSuite) TestFullMessage() {
|
|
||||||
type args struct {
|
|
||||||
message string
|
|
||||||
extraFields map[string]interface{}
|
|
||||||
}
|
|
||||||
extraFields := make(map[string]interface{})
|
|
||||||
extraFields["_full_message"] = "full message"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
args args
|
|
||||||
wantLevel string
|
|
||||||
wantMessage string
|
|
||||||
envMinLogLevel string
|
|
||||||
stdOutExpect bool
|
|
||||||
stdErrExpect bool
|
|
||||||
loggerType string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Test log: Error",
|
|
||||||
loggerType: "Error",
|
|
||||||
args: args{
|
|
||||||
message: "This is a error message",
|
|
||||||
extraFields: extraFields,
|
|
||||||
},
|
|
||||||
wantLevel: "error",
|
|
||||||
wantMessage: extraFields["_full_message"].(string),
|
|
||||||
stdErrExpect: true,
|
|
||||||
stdOutExpect: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Test log: Info",
|
|
||||||
loggerType: "Info",
|
|
||||||
args: args{
|
|
||||||
message: "This is a info message",
|
|
||||||
extraFields: extraFields,
|
|
||||||
},
|
|
||||||
wantMessage: extraFields["_full_message"].(string),
|
|
||||||
stdErrExpect: false,
|
|
||||||
stdOutExpect: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.T().Run(tt.name, func(t *testing.T) {
|
|
||||||
if tt.envMinLogLevel != "" {
|
|
||||||
os.Setenv("LOG_LEVEL", tt.envMinLogLevel)
|
|
||||||
defer os.Unsetenv("LOG_LEVEL")
|
|
||||||
}
|
|
||||||
testsLogger = NewLogger()
|
|
||||||
|
|
||||||
captureStdout := stdoutCapture{}
|
|
||||||
captureStdout.StartCapture()
|
|
||||||
captureStderr := stderrCapture{}
|
|
||||||
captureStderr.StartCapture()
|
|
||||||
|
|
||||||
reflect.ValueOf(testsLogger).MethodByName(tt.loggerType).Call([]reflect.Value{
|
|
||||||
reflect.ValueOf(tt.args.message),
|
|
||||||
reflect.ValueOf(tt.args.extraFields),
|
|
||||||
})
|
|
||||||
|
|
||||||
stdoutOut, err := captureStdout.StopCapture()
|
|
||||||
if err != nil {
|
|
||||||
suite.T().Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stderrOut, err := captureStderr.StopCapture()
|
|
||||||
if err != nil {
|
|
||||||
suite.T().Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.stdErrExpect && !tt.stdOutExpect {
|
|
||||||
_, gotMsg, err := getResponseValues(stderrOut, "_full_message")
|
|
||||||
suite.NoError(err, "Failed in [STDERR]: "+tt.name)
|
|
||||||
suite.Equal(tt.wantMessage, gotMsg, "Failed in [STDERR]: "+tt.name)
|
|
||||||
}
|
|
||||||
if tt.stdOutExpect && !tt.stdErrExpect {
|
|
||||||
_, gotMsg, err := getResponseValues(stdoutOut, "_full_message")
|
|
||||||
suite.NoError(err, "Failed in [STDOUT]: "+tt.name)
|
|
||||||
suite.Equal(tt.wantMessage, gotMsg, "Failed in [STDOUT]: "+tt.name)
|
|
||||||
}
|
|
||||||
os.Unsetenv("LOG_LEVEL")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_getResponseValues(t *testing.T) {
|
|
||||||
type args struct {
|
|
||||||
sourceJson string
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
args args
|
|
||||||
wantGotLvl string
|
|
||||||
wantGotMsg string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Test with json",
|
|
||||||
args: args{
|
|
||||||
sourceJson: `{"level": "debug", "short_message": "hello world"`,
|
|
||||||
},
|
|
||||||
wantGotLvl: "debug",
|
|
||||||
wantGotMsg: "hello world",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Test with json, wrong message field",
|
|
||||||
args: args{
|
|
||||||
sourceJson: `{"level": "debug", "message": "hello world"`,
|
|
||||||
},
|
|
||||||
wantGotLvl: "debug",
|
|
||||||
wantGotMsg: "",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
gotGotLvl, gotGotMsg, err := getResponseValues(tt.args.sourceJson, "short_message")
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("getResponseValues() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if gotGotLvl != tt.wantGotLvl {
|
|
||||||
t.Errorf("getResponseValues() gotGotLvl = %v, want %v", gotGotLvl, tt.wantGotLvl)
|
|
||||||
}
|
|
||||||
if gotGotMsg != tt.wantGotMsg {
|
|
||||||
t.Errorf("getResponseValues() gotGotMsg = %v, want %v", gotGotMsg, tt.wantGotMsg)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getResponseValues(sourceJson string, key string) (gotLvl, gotMsg string, err error) {
|
|
||||||
gotLvl, err = jsonparser.GetString([]byte(sourceJson), "level")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gotMsg, err = jsonparser.GetString([]byte(sourceJson), key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
libpack_logger "github.com/lukaszraczylo/go-simple-graphql/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// warmupConnectionPool pre-creates connections by executing warmup queries
|
||||||
|
// This establishes connections in the pool before actual traffic arrives
|
||||||
|
func (b *BaseClient) warmupConnectionPool() {
|
||||||
|
b.Logger.Info(&libpack_logger.LogMessage{
|
||||||
|
Message: "Warming up connection pool",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"pool_size": b.pool_size,
|
||||||
|
"warmup_query": b.pool_warmup_query,
|
||||||
|
"endpoint": b.endpoint,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
successCount := 0
|
||||||
|
failCount := 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
// Pre-create connections concurrently
|
||||||
|
for i := 0; i < b.pool_size; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Execute warmup query to establish connection
|
||||||
|
_, err := b.Query(b.pool_warmup_query, nil, nil)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
failCount++
|
||||||
|
b.Logger.Warning(&libpack_logger.LogMessage{
|
||||||
|
Message: "Failed to warm up connection",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"connection_index": index,
|
||||||
|
"error": err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
successCount++
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Connection warmed up successfully",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"connection_index": index,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
|
||||||
|
b.Logger.Info(&libpack_logger.LogMessage{
|
||||||
|
Message: "Connection pool warmup completed",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"successful": successCount,
|
||||||
|
"failed": failCount,
|
||||||
|
"total": b.pool_size,
|
||||||
|
"duration_ms": duration.Milliseconds(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPoolHealthMonitor starts a background goroutine that periodically
|
||||||
|
// checks and maintains pool health by sending health check queries
|
||||||
|
func (b *BaseClient) startPoolHealthMonitor() {
|
||||||
|
b.Logger.Info(&libpack_logger.LogMessage{
|
||||||
|
Message: "Starting connection pool health monitor",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"check_interval": b.pool_health_interval,
|
||||||
|
"pool_size": b.pool_size,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(b.pool_health_interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
b.performPoolHealthCheck()
|
||||||
|
case <-b.pool_stop:
|
||||||
|
b.Logger.Info(&libpack_logger.LogMessage{
|
||||||
|
Message: "Stopping connection pool health monitor",
|
||||||
|
Pairs: nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// performPoolHealthCheck executes health check queries to maintain pool connections
|
||||||
|
// This keeps connections alive and detects/replaces dead connections
|
||||||
|
func (b *BaseClient) performPoolHealthCheck() {
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Performing pool health check",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"pool_size": b.pool_size,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
healthyCount := 0
|
||||||
|
unhealthyCount := 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
// Check a subset of connections to ensure pool health
|
||||||
|
// We check pool_size/2 to balance between thoroughness and overhead
|
||||||
|
checkCount := max(1, b.pool_size/2)
|
||||||
|
|
||||||
|
for i := 0; i < checkCount; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Create a context with timeout for health check
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Use a channel to handle timeout
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := b.Query(b.pool_warmup_query, nil, nil)
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
mu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
unhealthyCount++
|
||||||
|
b.Logger.Warning(&libpack_logger.LogMessage{
|
||||||
|
Message: "Pool health check failed",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"check_index": index,
|
||||||
|
"error": err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
healthyCount++
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
case <-ctx.Done():
|
||||||
|
mu.Lock()
|
||||||
|
unhealthyCount++
|
||||||
|
b.Logger.Warning(&libpack_logger.LogMessage{
|
||||||
|
Message: "Pool health check timeout",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"check_index": index,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Pool health check completed",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"healthy": healthyCount,
|
||||||
|
"unhealthy": unhealthyCount,
|
||||||
|
"checked": checkCount,
|
||||||
|
"duration_ms": duration.Milliseconds(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// If more than 50% of checks failed, trigger a pool refresh
|
||||||
|
if unhealthyCount > checkCount/2 {
|
||||||
|
b.Logger.Warning(&libpack_logger.LogMessage{
|
||||||
|
Message: "Pool health degraded, triggering refresh",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"unhealthy_ratio": float64(unhealthyCount) / float64(checkCount),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
b.refreshConnectionPool()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshConnectionPool attempts to refresh the connection pool
|
||||||
|
// by executing warmup queries to replace stale connections
|
||||||
|
func (b *BaseClient) refreshConnectionPool() {
|
||||||
|
b.Logger.Info(&libpack_logger.LogMessage{
|
||||||
|
Message: "Refreshing connection pool",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"pool_size": b.pool_size,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
refreshCount := max(1, b.pool_size/3) // Refresh 1/3 of pool
|
||||||
|
|
||||||
|
for i := 0; i < refreshCount; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
_, err := b.Query(b.pool_warmup_query, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Pool refresh connection failed",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"connection_index": index,
|
||||||
|
"error": err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Pool connection refreshed",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"connection_index": index,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPoolMonitor gracefully stops the pool health monitor
|
||||||
|
// Call this when shutting down the application
|
||||||
|
// Uses sync.Once to ensure it only executes once, preventing race conditions
|
||||||
|
func (b *BaseClient) StopPoolMonitor() {
|
||||||
|
if b.pool_warmup_enabled {
|
||||||
|
b.pool_stop_once.Do(func() {
|
||||||
|
b.Logger.Info(&libpack_logger.LogMessage{
|
||||||
|
Message: "Stopping pool monitor",
|
||||||
|
Pairs: nil,
|
||||||
|
})
|
||||||
|
b.pool_stop <- true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (suite *Tests) TestPoolWarmup() {
|
||||||
|
suite.T().Run("should create client with pool warmup disabled by default", func(t *testing.T) {
|
||||||
|
// Save original env vars
|
||||||
|
originalPoolEnabled := os.Getenv("GRAPHQL_POOL_WARMUP_ENABLED")
|
||||||
|
originalEndpoint := os.Getenv("GRAPHQL_ENDPOINT")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("GRAPHQL_POOL_WARMUP_ENABLED", originalPoolEnabled)
|
||||||
|
os.Setenv("GRAPHQL_ENDPOINT", originalEndpoint)
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("GRAPHQL_POOL_WARMUP_ENABLED", "false")
|
||||||
|
os.Setenv("GRAPHQL_ENDPOINT", mockServer.URL)
|
||||||
|
|
||||||
|
client := NewConnection()
|
||||||
|
defer client.StopPoolMonitor()
|
||||||
|
|
||||||
|
assert.False(client.pool_warmup_enabled)
|
||||||
|
assert.Equal(5, client.pool_size) // Default size
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should warmup pool when enabled", func(t *testing.T) {
|
||||||
|
originalPoolEnabled := os.Getenv("GRAPHQL_POOL_WARMUP_ENABLED")
|
||||||
|
originalPoolSize := os.Getenv("GRAPHQL_POOL_SIZE")
|
||||||
|
originalEndpoint := os.Getenv("GRAPHQL_ENDPOINT")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("GRAPHQL_POOL_WARMUP_ENABLED", originalPoolEnabled)
|
||||||
|
os.Setenv("GRAPHQL_POOL_SIZE", originalPoolSize)
|
||||||
|
os.Setenv("GRAPHQL_ENDPOINT", originalEndpoint)
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("GRAPHQL_POOL_WARMUP_ENABLED", "true")
|
||||||
|
os.Setenv("GRAPHQL_POOL_SIZE", "3")
|
||||||
|
os.Setenv("GRAPHQL_ENDPOINT", mockServer.URL)
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
client := NewConnection()
|
||||||
|
defer client.StopPoolMonitor()
|
||||||
|
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
|
||||||
|
assert.True(client.pool_warmup_enabled)
|
||||||
|
assert.Equal(3, client.pool_size)
|
||||||
|
// Warmup should complete relatively quickly for 3 connections
|
||||||
|
assert.Less(duration.Seconds(), 5.0, "Pool warmup took too long")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should use custom warmup query", func(t *testing.T) {
|
||||||
|
originalPoolEnabled := os.Getenv("GRAPHQL_POOL_WARMUP_ENABLED")
|
||||||
|
originalQuery := os.Getenv("GRAPHQL_POOL_WARMUP_QUERY")
|
||||||
|
originalEndpoint := os.Getenv("GRAPHQL_ENDPOINT")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("GRAPHQL_POOL_WARMUP_ENABLED", originalPoolEnabled)
|
||||||
|
os.Setenv("GRAPHQL_POOL_WARMUP_QUERY", originalQuery)
|
||||||
|
os.Setenv("GRAPHQL_ENDPOINT", originalEndpoint)
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("GRAPHQL_POOL_WARMUP_ENABLED", "false") // Don't actually warmup, just test configuration
|
||||||
|
os.Setenv("GRAPHQL_POOL_WARMUP_QUERY", "query{__schema{queryType{name}}}")
|
||||||
|
os.Setenv("GRAPHQL_ENDPOINT", mockServer.URL)
|
||||||
|
|
||||||
|
client := NewConnection()
|
||||||
|
defer client.StopPoolMonitor()
|
||||||
|
|
||||||
|
assert.Equal("query{__schema{queryType{name}}}", client.pool_warmup_query)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestPoolHealthMonitor() {
|
||||||
|
suite.T().Run("should stop pool monitor gracefully", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
client.pool_warmup_enabled = false
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
client.StopPoolMonitor()
|
||||||
|
|
||||||
|
assert.True(true, "Handled stop gracefully when monitor not running")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle multiple stop calls", func(t *testing.T) {
|
||||||
|
originalPoolEnabled := os.Getenv("GRAPHQL_POOL_WARMUP_ENABLED")
|
||||||
|
originalPoolSize := os.Getenv("GRAPHQL_POOL_SIZE")
|
||||||
|
originalEndpoint := os.Getenv("GRAPHQL_ENDPOINT")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("GRAPHQL_POOL_WARMUP_ENABLED", originalPoolEnabled)
|
||||||
|
os.Setenv("GRAPHQL_POOL_SIZE", originalPoolSize)
|
||||||
|
os.Setenv("GRAPHQL_ENDPOINT", originalEndpoint)
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("GRAPHQL_POOL_WARMUP_ENABLED", "true")
|
||||||
|
os.Setenv("GRAPHQL_POOL_SIZE", "2")
|
||||||
|
os.Setenv("GRAPHQL_ENDPOINT", mockServer.URL)
|
||||||
|
|
||||||
|
client := NewConnection()
|
||||||
|
|
||||||
|
// Call stop multiple times - should not panic
|
||||||
|
client.StopPoolMonitor()
|
||||||
|
client.StopPoolMonitor()
|
||||||
|
client.StopPoolMonitor()
|
||||||
|
|
||||||
|
assert.True(true, "Handled multiple stop calls gracefully")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestPoolHealthCheck() {
|
||||||
|
suite.T().Run("should perform health check without panic", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
client.pool_warmup_enabled = true
|
||||||
|
client.pool_size = 2
|
||||||
|
client.pool_warmup_query = "query{__typename}"
|
||||||
|
|
||||||
|
// Perform a health check - should not panic
|
||||||
|
client.performPoolHealthCheck()
|
||||||
|
|
||||||
|
assert.True(true, "Health check completed")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should refresh pool without panic", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
client.pool_size = 2
|
||||||
|
client.pool_warmup_query = "query{__typename}"
|
||||||
|
|
||||||
|
// Perform pool refresh - should not panic
|
||||||
|
client.refreshConnectionPool()
|
||||||
|
|
||||||
|
assert.True(true, "Pool refresh completed")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Tiered buffer pools for different size requirements
|
||||||
|
smallBufferPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
// Small buffers for typical GraphQL queries (1KB)
|
||||||
|
b := bytes.NewBuffer(make([]byte, 0, 1024))
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mediumBufferPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
// Medium buffers for larger queries (4KB)
|
||||||
|
b := bytes.NewBuffer(make([]byte, 0, 4096))
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
largeBufferPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
// Large buffers for very large queries (16KB)
|
||||||
|
b := bytes.NewBuffer(make([]byte, 0, 16384))
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility - use medium pool as default
|
||||||
|
bufferPool *sync.Pool
|
||||||
|
|
||||||
|
// Pre-allocate error maps to reduce allocations
|
||||||
|
errPairsPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return make(map[string]interface{}, 1)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
bufferPool = &mediumBufferPool
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBuffer returns an appropriately sized buffer based on estimated size
|
||||||
|
func getBuffer(estimatedSize int) *bytes.Buffer {
|
||||||
|
switch {
|
||||||
|
case estimatedSize <= 1024:
|
||||||
|
return smallBufferPool.Get().(*bytes.Buffer)
|
||||||
|
case estimatedSize <= 4096:
|
||||||
|
return mediumBufferPool.Get().(*bytes.Buffer)
|
||||||
|
default:
|
||||||
|
return largeBufferPool.Get().(*bytes.Buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// putBuffer returns a buffer to the appropriate pool based on its capacity
|
||||||
|
func putBuffer(buf *bytes.Buffer) {
|
||||||
|
buf.Reset()
|
||||||
|
capacity := buf.Cap()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case capacity <= 1024:
|
||||||
|
smallBufferPool.Put(buf)
|
||||||
|
case capacity <= 4096:
|
||||||
|
mediumBufferPool.Put(buf)
|
||||||
|
default:
|
||||||
|
largeBufferPool.Put(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,95 +1,331 @@
|
|||||||
package gql
|
package gql
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"strings"
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/pandati"
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/gookit/goutil"
|
||||||
|
"github.com/gookit/goutil/strutil"
|
||||||
|
libpack_logger "github.com/lukaszraczylo/go-simple-graphql/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
type requestBase struct {
|
// minifyGraphQLQuery removes unnecessary whitespace from GraphQL queries while preserving string literals
|
||||||
Query string `json:"query"`
|
func minifyGraphQLQuery(query string) string {
|
||||||
Variables interface{} `json:"variables"`
|
if query == "" {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-allocate with estimated final size (typically 70-80% of original)
|
||||||
|
var result strings.Builder
|
||||||
|
result.Grow(len(query) * 3 / 4)
|
||||||
|
|
||||||
|
inString := false
|
||||||
|
stringChar := byte(0) // Track whether we're in single or double quotes
|
||||||
|
queryBytes := []byte(query) // Convert to bytes once for faster access
|
||||||
|
length := len(queryBytes)
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < length {
|
||||||
|
char := queryBytes[i]
|
||||||
|
|
||||||
|
// Handle string literals - preserve everything inside quotes
|
||||||
|
if !inString && (char == '"' || char == '\'') {
|
||||||
|
inString = true
|
||||||
|
stringChar = char
|
||||||
|
result.WriteByte(char)
|
||||||
|
i++
|
||||||
|
} else if inString && char == stringChar {
|
||||||
|
// Check if this quote is escaped (optimized version)
|
||||||
|
escaped := false
|
||||||
|
backslashCount := 0
|
||||||
|
for j := i - 1; j >= 0 && queryBytes[j] == '\\'; j-- {
|
||||||
|
backslashCount++
|
||||||
|
}
|
||||||
|
escaped = backslashCount%2 == 1
|
||||||
|
|
||||||
|
if !escaped {
|
||||||
|
inString = false
|
||||||
|
stringChar = 0
|
||||||
|
}
|
||||||
|
result.WriteByte(char)
|
||||||
|
i++
|
||||||
|
} else if inString {
|
||||||
|
// Inside string literal - preserve everything including whitespace
|
||||||
|
result.WriteByte(char)
|
||||||
|
i++
|
||||||
|
} else if isWhitespace(char) {
|
||||||
|
// Handle whitespace outside strings - optimized version
|
||||||
|
// Skip all consecutive whitespace
|
||||||
|
for i < length && isWhitespace(queryBytes[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < length && result.Len() > 0 {
|
||||||
|
// Get previous character from result buffer
|
||||||
|
resultStr := result.String()
|
||||||
|
prevChar := resultStr[len(resultStr)-1]
|
||||||
|
nextChar := queryBytes[i]
|
||||||
|
|
||||||
|
// Optimized space preservation logic
|
||||||
|
needsSpace := false
|
||||||
|
|
||||||
|
// Between alphanumeric characters (field names, variables, types)
|
||||||
|
if isAlphaNumeric(prevChar) && isAlphaNumeric(nextChar) {
|
||||||
|
needsSpace = true
|
||||||
|
} else if prevChar == '}' && isAlphaNumeric(nextChar) {
|
||||||
|
// Between closing brace and field name: "} field_name"
|
||||||
|
needsSpace = true
|
||||||
|
} else if prevChar == ':' && (isAlphaNumeric(nextChar) || nextChar == '$' || nextChar == '"' || nextChar == '\'' || nextChar == '{') {
|
||||||
|
// Around colons in type definitions and arguments: "$var: Type", "field: value"
|
||||||
|
needsSpace = true
|
||||||
|
} else if (isAlphaNumeric(prevChar) || prevChar == '_') && nextChar == ':' {
|
||||||
|
needsSpace = true
|
||||||
|
} else if prevChar == ',' && nextChar == '$' {
|
||||||
|
// After commas in argument lists: "arg1: value1, arg2: value2" - only for variables
|
||||||
|
needsSpace = true
|
||||||
|
} else if prevChar == ',' && isAlphaNumeric(nextChar) {
|
||||||
|
// Special case: space after comma when followed by field that has colon
|
||||||
|
// Look ahead to see if this field has a colon (limited lookahead for performance)
|
||||||
|
colonFound := false
|
||||||
|
lookAheadLimit := min(i+15, length) // Reduced lookahead for performance
|
||||||
|
for j := i + 1; j < lookAheadLimit; j++ {
|
||||||
|
if queryBytes[j] == ':' {
|
||||||
|
colonFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !isAlphaNumeric(queryBytes[j]) && queryBytes[j] != '_' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if colonFound {
|
||||||
|
needsSpace = true
|
||||||
|
}
|
||||||
|
} else if (prevChar == '!' && isAlphaNumeric(nextChar)) || (isAlphaNumeric(prevChar) && nextChar == '!') {
|
||||||
|
// Around exclamation marks in type definitions: "Type !"
|
||||||
|
needsSpace = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsSpace {
|
||||||
|
result.WriteByte(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular character - just copy it
|
||||||
|
result.WriteByte(char)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
type queryResults struct {
|
// isAlphaNumeric checks if a character is alphanumeric or underscore
|
||||||
Data interface{} `json:"data"`
|
func isAlphaNumeric(char byte) bool {
|
||||||
Errors []struct {
|
return (char >= 'a' && char <= 'z') ||
|
||||||
Message interface{} `json:"message"`
|
(char >= 'A' && char <= 'Z') ||
|
||||||
} `json:"errors"`
|
(char >= '0' && char <= '9') ||
|
||||||
|
char == '_'
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GraphQL) queryBuilder(queryContent string, queryVariables interface{}) ([]byte, error) {
|
// isWhitespace checks if a character is whitespace
|
||||||
var qb = &requestBase{
|
func isWhitespace(char byte) bool {
|
||||||
Query: queryContent,
|
return char == ' ' || char == '\t' || char == '\n' || char == '\r'
|
||||||
Variables: queryVariables,
|
|
||||||
}
|
|
||||||
|
|
||||||
// j := new(bytes.Buffer)
|
|
||||||
j2, err := json.Marshal(qb)
|
|
||||||
if err != nil {
|
|
||||||
g.Log.Critical("Unable to marshal the query", map[string]interface{}{"_error": err.Error(), "_query": queryContent, "_variables": queryVariables})
|
|
||||||
return []byte{}, err
|
|
||||||
}
|
|
||||||
// if err = json.Compact(j, j2); err != nil {
|
|
||||||
// g.Log.Critical("Unable to compact the query", map[string]interface{}{"error": err.Error()})
|
|
||||||
// return []byte{}, err
|
|
||||||
// }
|
|
||||||
return j2, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GraphQL) Query(queryContent string, queryVariables interface{}, queryHeaders map[string]interface{}) (responseContent string, err error) {
|
func (b *BaseClient) convertToJSON(v any) []byte {
|
||||||
g.Log.Debug("Query details", map[string]interface{}{"_query": queryContent, "_variables": queryVariables})
|
// Estimate size based on query structure for better buffer selection
|
||||||
query, err := g.queryBuilder(queryContent, queryVariables)
|
estimatedSize := 512 // Base size
|
||||||
if err != nil {
|
if query, ok := v.(*Query); ok {
|
||||||
g.Log.Error("Unable to build the query", map[string]interface{}{"_error": err.Error()})
|
estimatedSize += len(query.Query)
|
||||||
return "", err
|
if query.Variables != nil {
|
||||||
}
|
estimatedSize += len(query.Variables) * 50 // Rough estimate per variable
|
||||||
httpRequest, err := http.NewRequest("POST", g.Endpoint, bytes.NewBuffer(query))
|
}
|
||||||
httpRequest.Header.Add("Content-Type", "application/json")
|
|
||||||
for header, value := range queryHeaders {
|
|
||||||
httpRequest.Header.Add(fmt.Sprintf("%v", header), fmt.Sprintf("%v", value))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
httpResponse, err := g.HttpClient.Do(httpRequest)
|
buf := getBuffer(estimatedSize)
|
||||||
if err != nil {
|
buf.Reset() // Ensure buffer is clean before use
|
||||||
g.Log.Error("Unable to send the query", map[string]interface{}{"_error": err.Error()})
|
defer putBuffer(buf)
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer io.Copy(ioutil.Discard, httpResponse.Body)
|
|
||||||
defer httpResponse.Body.Close()
|
|
||||||
|
|
||||||
body, err := ioutil.ReadAll(httpResponse.Body)
|
// Use json.NewEncoder for better performance with buffers
|
||||||
if err != nil {
|
enc := json.NewEncoder(buf)
|
||||||
g.Log.Critical("Unable to read the response", map[string]interface{}{"_error": err.Error()})
|
enc.SetEscapeHTML(false) // Reduce unnecessary escaping
|
||||||
return "", err
|
|
||||||
}
|
if err := enc.Encode(v); err != nil {
|
||||||
var queryResult *queryResults
|
errPairs := errPairsPool.Get().(map[string]interface{})
|
||||||
err = json.Unmarshal(body, &queryResult)
|
errPairs["error"] = err.Error()
|
||||||
if err != nil {
|
b.Logger.Error(&libpack_logger.LogMessage{
|
||||||
g.Log.Error("Unable to unmarshal the query", map[string]interface{}{"_error": err.Error()})
|
Message: "Can't convert to JSON",
|
||||||
return "", err
|
Pairs: errPairs,
|
||||||
|
})
|
||||||
|
errPairsPool.Put(errPairs)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !pandati.IsZero(queryResult.Errors) {
|
// Get the buffer bytes directly, trimming the trailing newline
|
||||||
g.Log.Error("Query returned error", map[string]interface{}{"_query": queryContent, "_variables": queryVariables, "_error": fmt.Sprintf("%v", queryResult.Errors)})
|
bytes := buf.Bytes()
|
||||||
return "", fmt.Errorf("%v", queryResult.Errors[0].Message)
|
if len(bytes) > 0 && bytes[len(bytes)-1] == '\n' {
|
||||||
|
bytes = bytes[:len(bytes)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
if pandati.IsZero(queryResult.Data) {
|
// Make a copy of the bytes since the buffer will be reused
|
||||||
g.Log.Error("Query returned no data", map[string]interface{}{"_query": queryContent, "_variables": queryVariables})
|
result := make([]byte, len(bytes))
|
||||||
return "", errors.New("Query returned no data")
|
copy(result, bytes)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func processFlags(variables map[string]interface{}, headers map[string]interface{}) (enableCache, enableRetries bool, cleanedVariables map[string]interface{}) {
|
||||||
|
// Start with original variables
|
||||||
|
cleanedVariables = variables
|
||||||
|
|
||||||
|
// Check headers first
|
||||||
|
enableCache, _ = goutil.ToBool(searchForKeysInMapStringInterface(headers, "gqlcache"))
|
||||||
|
enableRetries, _ = goutil.ToBool(searchForKeysInMapStringInterface(headers, "gqlretries"))
|
||||||
|
|
||||||
|
// Check variables and clean them if needed
|
||||||
|
if variables != nil {
|
||||||
|
varEnableCache, _ := goutil.ToBool(searchForKeysInMapStringInterface(variables, "gqlcache"))
|
||||||
|
varEnableRetries, _ := goutil.ToBool(searchForKeysInMapStringInterface(variables, "gqlretries"))
|
||||||
|
|
||||||
|
enableCache = enableCache || varEnableCache
|
||||||
|
enableRetries = enableRetries || varEnableRetries
|
||||||
|
|
||||||
|
// Clean variables if flags are present (regardless of their value)
|
||||||
|
_, hasCacheFlag := variables["gqlcache"]
|
||||||
|
_, hasRetriesFlag := variables["gqlretries"]
|
||||||
|
|
||||||
|
if hasCacheFlag || hasRetriesFlag {
|
||||||
|
cleanedVariables = make(map[string]interface{})
|
||||||
|
for k, v := range variables {
|
||||||
|
if k != "gqlcache" && k != "gqlretries" {
|
||||||
|
cleanedVariables[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return enableCache, enableRetries, cleanedVariables
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseClient) compileQuery(queryPartials ...any) *Query {
|
||||||
|
var query string
|
||||||
|
var variables map[string]interface{}
|
||||||
|
|
||||||
|
// Pre-allocate the query with an estimated size
|
||||||
|
if len(queryPartials) > 0 {
|
||||||
|
if str, ok := queryPartials[0].(string); ok {
|
||||||
|
query = str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allocate variables map if we have more than one partial
|
||||||
|
if len(queryPartials) > 1 {
|
||||||
|
if vars, ok := queryPartials[1].(map[string]interface{}); ok {
|
||||||
|
variables = vars
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
errPairs := errPairsPool.Get().(map[string]interface{})
|
||||||
|
errPairs["error"] = "query is empty"
|
||||||
|
b.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Can't compile query",
|
||||||
|
Pairs: errPairs,
|
||||||
|
})
|
||||||
|
errPairsPool.Put(errPairs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply query minification if enabled (default: true)
|
||||||
|
finalQuery := query
|
||||||
|
if b.minify_queries {
|
||||||
|
originalSize := len(query)
|
||||||
|
minifiedQuery := minifyGraphQLQuery(query)
|
||||||
|
minifiedSize := len(minifiedQuery)
|
||||||
|
|
||||||
|
// Log the minification results if there was a reduction
|
||||||
|
if originalSize != minifiedSize {
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "GraphQL query minified",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"original_size": originalSize,
|
||||||
|
"minified_size": minifiedSize,
|
||||||
|
"size_reduction": originalSize - minifiedSize,
|
||||||
|
"reduction_pct": float64(originalSize-minifiedSize) / float64(originalSize) * 100,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finalQuery = minifiedQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct query object once with final query
|
||||||
|
q := &Query{
|
||||||
|
Query: finalQuery,
|
||||||
|
Variables: variables,
|
||||||
|
}
|
||||||
|
q.JsonQuery = b.convertToJSON(q)
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseClient) Query(query string, variables map[string]interface{}, headers map[string]interface{}) (any, error) {
|
||||||
|
// Process flags before compilation to avoid recompilation
|
||||||
|
enableCache, enableRetries, cleanedVariables := processFlags(variables, headers)
|
||||||
|
|
||||||
|
// Compile query once with cleaned variables
|
||||||
|
compiledQuery := b.compileQuery(query, cleanedVariables)
|
||||||
|
if compiledQuery == nil || compiledQuery.JsonQuery == nil {
|
||||||
|
b.Logger.Error(&libpack_logger.LogMessage{
|
||||||
|
Message: "Can't compile query",
|
||||||
|
Pairs: map[string]interface{}{"error": "query is empty"},
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("can't compile query")
|
||||||
|
}
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Compiled query",
|
||||||
|
Pairs: map[string]interface{}{"query": compiledQuery},
|
||||||
|
})
|
||||||
|
|
||||||
|
var queryHash string
|
||||||
|
if (enableCache || b.cache_global) && strutil.HasPrefix(compiledQuery.Query, "query") {
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Cache enabled",
|
||||||
|
Pairs: nil,
|
||||||
|
})
|
||||||
|
queryHash = calculateHash(compiledQuery)
|
||||||
|
if cachedValue := b.cacheLookup(queryHash); cachedValue != nil {
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Cache hit",
|
||||||
|
Pairs: map[string]interface{}{"query": compiledQuery},
|
||||||
|
})
|
||||||
|
return b.decodeResponse(cachedValue)
|
||||||
|
}
|
||||||
|
b.Logger.Debug(&libpack_logger.LogMessage{
|
||||||
|
Message: "Cache miss",
|
||||||
|
Pairs: map[string]interface{}{"query": compiledQuery},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
q := &QueryExecutor{
|
||||||
|
BaseClient: b,
|
||||||
|
Query: compiledQuery.JsonQuery,
|
||||||
|
Headers: headers,
|
||||||
|
CacheKey: func() string {
|
||||||
|
if queryHash != "" {
|
||||||
|
return queryHash
|
||||||
|
}
|
||||||
|
return "no-cache"
|
||||||
|
}(),
|
||||||
|
Retries: enableRetries || b.retries_enable,
|
||||||
}
|
}
|
||||||
|
|
||||||
responseContent, err = json.MarshalToString(queryResult.Data)
|
rv, err := q.executeQuery()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
g.Log.Error("Invalid data result", map[string]interface{}{"_query": queryContent, "_variables": queryVariables, "_result": responseContent})
|
b.Logger.Error(&libpack_logger.LogMessage{
|
||||||
return "", err
|
Message: "Error executing query",
|
||||||
|
Pairs: map[string]interface{}{"error": err.Error()},
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return b.decodeResponse(rv)
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-59
@@ -4,66 +4,96 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Benchmark_GraphQL_Query(t *testing.B) {
|
func Benchmark_compileQuery(bn *testing.B) {
|
||||||
if testing.Short() {
|
b := NewConnection()
|
||||||
t.Skip("Skipping test in short / CI mode")
|
bn.ResetTimer()
|
||||||
}
|
for i := 0; i < bn.N; i++ {
|
||||||
type args struct {
|
b.compileQuery(
|
||||||
queryContent string
|
`query { viewer { login } }`,
|
||||||
queryVariables interface{}
|
)
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
endpoint string
|
|
||||||
isLocal bool
|
|
||||||
args args
|
|
||||||
wantResult string
|
|
||||||
mockedReply string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Valid query",
|
|
||||||
endpoint: "http://hasura.local/v1/graphql",
|
|
||||||
isLocal: false,
|
|
||||||
args: args{
|
|
||||||
queryContent: `query listUserBots {
|
|
||||||
tbl_bots(limit: 1) {
|
|
||||||
bot_name
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
queryVariables: nil,
|
|
||||||
},
|
|
||||||
wantResult: `{"tbl_bots":[{"bot_name":"littleMentionBot"}]}`,
|
|
||||||
mockedReply: `{"data":{"tbl_bots":[{"bot_name":"littleMentionBot"}]}}`,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid query",
|
|
||||||
endpoint: "http://hasura.local/v1/graphql",
|
|
||||||
isLocal: false,
|
|
||||||
args: args{
|
|
||||||
queryContent: `query listUserBots {
|
|
||||||
tbl_botz(limit: 1) {
|
|
||||||
bot_name
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
queryVariables: nil,
|
|
||||||
},
|
|
||||||
mockedReply: `{"errors":[{"message":"Unknown field \"tbl_botz\" on type \"Query\""}]}`,
|
|
||||||
wantResult: ``,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
func Benchmark_compileQueryWithVariables(bn *testing.B) {
|
||||||
t.Run(tt.name, func(t *testing.B) {
|
b := NewConnection()
|
||||||
g := NewConnection()
|
variables := map[string]interface{}{
|
||||||
server, serverURL := mockGraphQLServerResponses(tt.mockedReply)
|
"var1": "value1",
|
||||||
g.Endpoint = serverURL
|
}
|
||||||
for n := 0; n < t.N; n++ {
|
bn.ResetTimer()
|
||||||
g.Query(tt.args.queryContent, tt.args.queryVariables, nil)
|
for i := 0; i < bn.N; i++ {
|
||||||
}
|
b.compileQuery(
|
||||||
server.Close()
|
`query { viewer { login } }`,
|
||||||
})
|
variables,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Benchmark_convertToJson(bn *testing.B) {
|
||||||
|
b := NewConnection()
|
||||||
|
query := `query { viewer { login } }`
|
||||||
|
bn.ResetTimer()
|
||||||
|
for i := 0; i < bn.N; i++ {
|
||||||
|
b.convertToJSON(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func Benchmark_Query(bn *testing.B) {
|
||||||
|
mockServer := StartMockServer()
|
||||||
|
bn.Cleanup(func() {
|
||||||
|
mockServer.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
b := NewConnection()
|
||||||
|
b.SetEndpoint(mockServer.URL)
|
||||||
|
query := "query { viewer { login } }"
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"var1": "value1",
|
||||||
|
}
|
||||||
|
headers := map[string]interface{}{
|
||||||
|
"Authorization": "Bearer token",
|
||||||
|
"gqlcache": "true",
|
||||||
|
}
|
||||||
|
bn.ResetTimer()
|
||||||
|
for i := 0; i < bn.N; i++ {
|
||||||
|
_, _ = b.Query(query, variables, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Benchmark_QueryNoCacheNoRetry(bn *testing.B) {
|
||||||
|
mockServer := StartMockServer()
|
||||||
|
bn.Cleanup(func() {
|
||||||
|
mockServer.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
b := NewConnection()
|
||||||
|
b.SetEndpoint(mockServer.URL)
|
||||||
|
query := `query { viewer { login } }`
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"var1": "value1",
|
||||||
|
}
|
||||||
|
headers := map[string]interface{}{
|
||||||
|
"Authorization": "Bearer token",
|
||||||
|
}
|
||||||
|
bn.ResetTimer()
|
||||||
|
for i := 0; i < bn.N; i++ {
|
||||||
|
_, _ = b.Query(query, variables, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Benchmark_QueryWithEmptyVariables(bn *testing.B) {
|
||||||
|
mockServer := StartMockServer()
|
||||||
|
bn.Cleanup(func() {
|
||||||
|
mockServer.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
b := NewConnection()
|
||||||
|
b.SetEndpoint(mockServer.URL)
|
||||||
|
query := `query { viewer { login } }`
|
||||||
|
headers := map[string]interface{}{
|
||||||
|
"Authorization": "Bearer token",
|
||||||
|
"gqlcache": "true",
|
||||||
|
}
|
||||||
|
bn.ResetTimer()
|
||||||
|
for i := 0; i < bn.N; i++ {
|
||||||
|
_, _ = b.Query(query, nil, headers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
assertions "github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMinifyGraphQLQuery(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty_query",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple_query_no_whitespace",
|
||||||
|
input: "query{viewer{login}}",
|
||||||
|
expected: "query{viewer{login}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query_with_newlines_and_tabs",
|
||||||
|
input: `query {
|
||||||
|
viewer {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
expected: "query{viewer{login}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mutation_with_variables",
|
||||||
|
input: `mutation storeMessage($content: String!, $id: Int!) {
|
||||||
|
insert_message(content: $content, id: $id) {
|
||||||
|
id
|
||||||
|
created_at
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
expected: "mutation storeMessage($content: String!, $id: Int!){insert_message(content: $content, id: $id){id created_at}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query_with_string_literals_preserved",
|
||||||
|
input: `mutation storeMessage($description: String!) {
|
||||||
|
insert_message(description: $description, content: "This is a test\nwith newlines\tand tabs") {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
expected: `mutation storeMessage($description: String!){insert_message(description: $description, content: "This is a test\nwith newlines\tand tabs"){id}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex_query_with_nested_fields",
|
||||||
|
input: `query Dragons {
|
||||||
|
dragons {
|
||||||
|
name
|
||||||
|
type
|
||||||
|
abilities {
|
||||||
|
name
|
||||||
|
power
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
expected: "query Dragons{dragons{name type abilities{name power}}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query_with_single_quotes",
|
||||||
|
input: `mutation test($value: String!) {
|
||||||
|
insert_test(value: $value, note: 'Single quoted string with\nnewlines') {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
expected: `mutation test($value: String!){insert_test(value: $value, note: 'Single quoted string with\nnewlines'){id}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query_with_escaped_quotes",
|
||||||
|
input: `mutation test($value: String!) {
|
||||||
|
insert_test(value: $value, note: "String with \"escaped quotes\" inside") {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
expected: `mutation test($value: String!){insert_test(value: $value, note: "String with \"escaped quotes\" inside"){id}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "production_case_from_trailing_garbage_test",
|
||||||
|
input: `mutation storeProcessedMessage($content: jsonb, $telegram_msg_id: bigint!, $hasMedia: Boolean!, $urls: jsonb, $groupID: bigint!, $userID: bigint!, $entities: jsonb) {
|
||||||
|
insert_tg_messages_one(object: {content: $content, telegram_msg_id: $telegram_msg_id, has_media: $hasMedia, urls: $urls, group_id: $groupID, user_id: $userID, entities: $entities}) {
|
||||||
|
id
|
||||||
|
tg_group {
|
||||||
|
tg_group_admins_aggregate(where: {user_id: {_eq: $userID}}) {
|
||||||
|
aggregate {
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set_spam_ai
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
expected: "mutation storeProcessedMessage($content: jsonb, $telegram_msg_id: bigint!, $hasMedia: Boolean!, $urls: jsonb, $groupID: bigint!, $userID: bigint!, $entities: jsonb){insert_tg_messages_one(object: {content: $content,telegram_msg_id: $telegram_msg_id, has_media: $hasMedia, urls: $urls, group_id: $groupID, user_id: $userID, entities: $entities}){id tg_group{tg_group_admins_aggregate(where: {user_id: {_eq: $userID}}){aggregate{count}} set_spam_ai}}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query_with_mixed_quotes_and_escapes",
|
||||||
|
input: `mutation test {
|
||||||
|
insert_test(
|
||||||
|
single: 'Single quote with "double" inside',
|
||||||
|
double: "Double quote with 'single' inside",
|
||||||
|
escaped: "Escaped \"double\" quotes"
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
expected: `mutation test{insert_test(single: 'Single quote with "double" inside', double: "Double quote with 'single' inside", escaped: "Escaped \"double\" quotes"){id}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query_with_unicode_in_strings",
|
||||||
|
input: `mutation storeMessage($user_name: String!) {
|
||||||
|
insert_message(
|
||||||
|
user_name: $user_name,
|
||||||
|
description: "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈"
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
expected: `mutation storeMessage($user_name: String!){insert_message(user_name: $user_name, description: "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈"){id}}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := minifyGraphQLQuery(tt.input)
|
||||||
|
assert.Equal(tt.expected, result, "Minified query should match expected output")
|
||||||
|
|
||||||
|
// Verify that the result doesn't contain unnecessary whitespace
|
||||||
|
assert.False(strings.Contains(result, "\n"), "Result should not contain newlines")
|
||||||
|
assert.False(strings.Contains(result, "\t"), "Result should not contain tabs")
|
||||||
|
|
||||||
|
// Verify that string literals are preserved (if they exist in expected)
|
||||||
|
if strings.Contains(tt.expected, `\n`) {
|
||||||
|
assert.True(strings.Contains(result, `\n`), "Newlines in string literals should be preserved")
|
||||||
|
}
|
||||||
|
if strings.Contains(tt.expected, `\t`) {
|
||||||
|
assert.True(strings.Contains(result, `\t`), "Tabs in string literals should be preserved")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinifyGraphQLQuerySizeReduction(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
// Test with a heavily formatted query
|
||||||
|
heavyQuery := `
|
||||||
|
mutation storeProcessedMessage(
|
||||||
|
$content: jsonb,
|
||||||
|
$telegram_msg_id: bigint!,
|
||||||
|
$hasMedia: Boolean!,
|
||||||
|
$urls: jsonb,
|
||||||
|
$groupID: bigint!,
|
||||||
|
$userID: bigint!,
|
||||||
|
$entities: jsonb
|
||||||
|
) {
|
||||||
|
insert_tg_messages_one(
|
||||||
|
object: {
|
||||||
|
content: $content,
|
||||||
|
telegram_msg_id: $telegram_msg_id,
|
||||||
|
has_media: $hasMedia,
|
||||||
|
urls: $urls,
|
||||||
|
group_id: $groupID,
|
||||||
|
user_id: $userID,
|
||||||
|
entities: $entities
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
tg_group {
|
||||||
|
tg_group_admins_aggregate(
|
||||||
|
where: {
|
||||||
|
user_id: {
|
||||||
|
_eq: $userID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
aggregate {
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set_spam_ai
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
originalSize := len(heavyQuery)
|
||||||
|
minified := minifyGraphQLQuery(heavyQuery)
|
||||||
|
minifiedSize := len(minified)
|
||||||
|
|
||||||
|
t.Logf("Original size: %d bytes", originalSize)
|
||||||
|
t.Logf("Minified size: %d bytes", minifiedSize)
|
||||||
|
t.Logf("Size reduction: %d bytes (%.1f%%)", originalSize-minifiedSize, float64(originalSize-minifiedSize)/float64(originalSize)*100)
|
||||||
|
|
||||||
|
// Should achieve significant size reduction
|
||||||
|
assert.True(minifiedSize < originalSize, "Minified query should be smaller than original")
|
||||||
|
reductionPercent := float64(originalSize-minifiedSize) / float64(originalSize) * 100
|
||||||
|
assert.True(reductionPercent > 30, "Should achieve at least 30% size reduction for heavily formatted queries")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseClientQueryMinification(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
t.Run("should_minify_queries_by_default", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Verify minification is enabled by default
|
||||||
|
assert.True(client.minify_queries, "Query minification should be enabled by default")
|
||||||
|
|
||||||
|
query := `query {
|
||||||
|
viewer {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
variables := map[string]interface{}{}
|
||||||
|
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
assert.NotNil(compiledQuery)
|
||||||
|
|
||||||
|
// The compiled query should be minified (no newlines/tabs)
|
||||||
|
assert.False(strings.Contains(compiledQuery.Query, "\n"), "Compiled query should not contain newlines")
|
||||||
|
assert.False(strings.Contains(compiledQuery.Query, "\t"), "Compiled query should not contain tabs")
|
||||||
|
assert.Equal("query{viewer{login}}", compiledQuery.Query)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should_respect_minification_disabled", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
client.SetQueryMinification(false)
|
||||||
|
|
||||||
|
// Verify minification is disabled
|
||||||
|
assert.False(client.minify_queries, "Query minification should be disabled")
|
||||||
|
|
||||||
|
query := `query {
|
||||||
|
viewer {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
variables := map[string]interface{}{}
|
||||||
|
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
assert.NotNil(compiledQuery)
|
||||||
|
|
||||||
|
// The compiled query should NOT be minified (preserve original formatting)
|
||||||
|
assert.Equal(query, compiledQuery.Query, "Query should remain unchanged when minification is disabled")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should_handle_string_literals_correctly", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
query := `mutation test($value: String!) {
|
||||||
|
insert_test(
|
||||||
|
value: $value,
|
||||||
|
note: "This is a test\nwith newlines\tand tabs"
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"value": "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
assert.NotNil(compiledQuery)
|
||||||
|
|
||||||
|
// String literals should preserve their content
|
||||||
|
assert.Contains(compiledQuery.Query, `"This is a test\nwith newlines\tand tabs"`, "String literals should be preserved")
|
||||||
|
// But the query structure should be minified
|
||||||
|
assert.False(strings.Contains(strings.Replace(compiledQuery.Query, `"This is a test\nwith newlines\tand tabs"`, "", 1), "\n"), "Query structure should be minified")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should_demonstrate_size_reduction_with_production_case", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Use the production case from trailing garbage test
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
originalQuery := `mutation storeMessage(
|
||||||
|
$group_description: String!,
|
||||||
|
$user_name: String!
|
||||||
|
) {
|
||||||
|
insert_message(
|
||||||
|
group_description: $group_description,
|
||||||
|
user_name: $user_name
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// Test with minification enabled
|
||||||
|
client.SetQueryMinification(true)
|
||||||
|
compiledQueryMinified := client.compileQuery(originalQuery, variables)
|
||||||
|
minifiedJsonSize := len(compiledQueryMinified.JsonQuery)
|
||||||
|
|
||||||
|
// Test with minification disabled
|
||||||
|
client.SetQueryMinification(false)
|
||||||
|
compiledQueryOriginal := client.compileQuery(originalQuery, variables)
|
||||||
|
originalJsonSize := len(compiledQueryOriginal.JsonQuery)
|
||||||
|
|
||||||
|
t.Logf("Original JSON size: %d bytes", originalJsonSize)
|
||||||
|
t.Logf("Minified JSON size: %d bytes", minifiedJsonSize)
|
||||||
|
t.Logf("Size reduction: %d bytes", originalJsonSize-minifiedJsonSize)
|
||||||
|
|
||||||
|
// Should achieve size reduction
|
||||||
|
assert.True(minifiedJsonSize < originalJsonSize, "Minified JSON should be smaller")
|
||||||
|
|
||||||
|
// Both should be valid JSON
|
||||||
|
assert.True(len(compiledQueryMinified.JsonQuery) > 0, "Minified query should produce valid JSON")
|
||||||
|
assert.True(len(compiledQueryOriginal.JsonQuery) > 0, "Original query should produce valid JSON")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAlphaNumeric(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
char byte
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{'a', true},
|
||||||
|
{'z', true},
|
||||||
|
{'A', true},
|
||||||
|
{'Z', true},
|
||||||
|
{'0', true},
|
||||||
|
{'9', true},
|
||||||
|
{'_', true},
|
||||||
|
{' ', false},
|
||||||
|
{'\n', false},
|
||||||
|
{'\t', false},
|
||||||
|
{'{', false},
|
||||||
|
{'}', false},
|
||||||
|
{'(', false},
|
||||||
|
{')', false},
|
||||||
|
{':', false},
|
||||||
|
{'"', false},
|
||||||
|
{'\'', false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.char), func(t *testing.T) {
|
||||||
|
result := isAlphaNumeric(tt.char)
|
||||||
|
assert.Equal(tt.expected, result, "isAlphaNumeric should correctly identify character type")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryMinificationEnvironmentVariable(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
t.Run("should_respect_GRAPHQL_MINIFY_QUERIES_env_var", func(t *testing.T) {
|
||||||
|
// This test would require setting environment variables
|
||||||
|
// For now, we'll test the default behavior
|
||||||
|
client := NewConnection()
|
||||||
|
|
||||||
|
// Should be enabled by default
|
||||||
|
assert.True(client.minify_queries, "Query minification should be enabled by default")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryMinificationEdgeCases(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
t.Run("should_handle_empty_query", func(t *testing.T) {
|
||||||
|
result := minifyGraphQLQuery("")
|
||||||
|
assert.Equal("", result, "Empty query should remain empty")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should_handle_query_with_only_whitespace", func(t *testing.T) {
|
||||||
|
result := minifyGraphQLQuery(" \n\t \n ")
|
||||||
|
assert.Equal("", result, "Query with only whitespace should become empty")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should_handle_nested_quotes", func(t *testing.T) {
|
||||||
|
query := `mutation test {
|
||||||
|
insert_test(value: "String with \"nested quotes\" and 'mixed' quotes") {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
result := minifyGraphQLQuery(query)
|
||||||
|
expected := `mutation test{insert_test(value: "String with \"nested quotes\" and 'mixed' quotes"){id}}`
|
||||||
|
assert.Equal(expected, result, "Should handle nested quotes correctly")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should_preserve_spaces_between_keywords", func(t *testing.T) {
|
||||||
|
query := `query test($var: String!) {
|
||||||
|
field(where: {id: {_eq: $var}}) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
result := minifyGraphQLQuery(query)
|
||||||
|
|
||||||
|
// Should preserve necessary spaces
|
||||||
|
assert.Contains(result, "query test", "Should preserve space between 'query' and 'test'")
|
||||||
|
assert.Contains(result, "$var: String!", "Should preserve space in variable declaration")
|
||||||
|
assert.Contains(result, "_eq: $var", "Should preserve space around operators")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QueryOptimizationTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
client *BaseClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *QueryOptimizationTestSuite) SetupTest() {
|
||||||
|
suite.client = CreateTestClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *QueryOptimizationTestSuite) TearDownTest() {
|
||||||
|
// No need to stop shared cache - it will be cleaned up globally
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *QueryOptimizationTestSuite) TestProcessFlags() {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
variables map[string]interface{}
|
||||||
|
headers map[string]interface{}
|
||||||
|
expectedCache bool
|
||||||
|
expectedRetries bool
|
||||||
|
expectedVarCount int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "No flags",
|
||||||
|
variables: map[string]interface{}{"id": "123"},
|
||||||
|
headers: map[string]interface{}{},
|
||||||
|
expectedCache: false,
|
||||||
|
expectedRetries: false,
|
||||||
|
expectedVarCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cache flag in headers",
|
||||||
|
variables: map[string]interface{}{"id": "123"},
|
||||||
|
headers: map[string]interface{}{"gqlcache": true},
|
||||||
|
expectedCache: true,
|
||||||
|
expectedRetries: false,
|
||||||
|
expectedVarCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Retries flag in headers",
|
||||||
|
variables: map[string]interface{}{"id": "123"},
|
||||||
|
headers: map[string]interface{}{"gqlretries": true},
|
||||||
|
expectedCache: false,
|
||||||
|
expectedRetries: true,
|
||||||
|
expectedVarCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cache flag in variables",
|
||||||
|
variables: map[string]interface{}{"id": "123", "gqlcache": true},
|
||||||
|
headers: map[string]interface{}{},
|
||||||
|
expectedCache: true,
|
||||||
|
expectedRetries: false,
|
||||||
|
expectedVarCount: 1, // gqlcache should be removed
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Retries flag in variables",
|
||||||
|
variables: map[string]interface{}{"id": "123", "gqlretries": true},
|
||||||
|
headers: map[string]interface{}{},
|
||||||
|
expectedCache: false,
|
||||||
|
expectedRetries: true,
|
||||||
|
expectedVarCount: 1, // gqlretries should be removed
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Both flags in variables",
|
||||||
|
variables: map[string]interface{}{"id": "123", "gqlcache": true, "gqlretries": true},
|
||||||
|
headers: map[string]interface{}{},
|
||||||
|
expectedCache: true,
|
||||||
|
expectedRetries: true,
|
||||||
|
expectedVarCount: 1, // both flags should be removed
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Flags in both headers and variables",
|
||||||
|
variables: map[string]interface{}{"id": "123", "gqlcache": false},
|
||||||
|
headers: map[string]interface{}{"gqlcache": true, "gqlretries": true},
|
||||||
|
expectedCache: true, // header value OR variable value
|
||||||
|
expectedRetries: true,
|
||||||
|
expectedVarCount: 1, // gqlcache should be removed from variables
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Nil variables",
|
||||||
|
variables: nil,
|
||||||
|
headers: map[string]interface{}{"gqlcache": true},
|
||||||
|
expectedCache: true,
|
||||||
|
expectedRetries: false,
|
||||||
|
expectedVarCount: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
enableCache, enableRetries, cleanedVariables := processFlags(tt.variables, tt.headers)
|
||||||
|
|
||||||
|
suite.Equal(tt.expectedCache, enableCache, "Cache flag mismatch")
|
||||||
|
suite.Equal(tt.expectedRetries, enableRetries, "Retries flag mismatch")
|
||||||
|
|
||||||
|
if tt.expectedVarCount == 0 {
|
||||||
|
suite.Nil(cleanedVariables, "Variables should be nil")
|
||||||
|
} else {
|
||||||
|
suite.Len(cleanedVariables, tt.expectedVarCount, "Variable count mismatch")
|
||||||
|
suite.NotContains(cleanedVariables, "gqlcache", "gqlcache should be removed")
|
||||||
|
suite.NotContains(cleanedVariables, "gqlretries", "gqlretries should be removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *QueryOptimizationTestSuite) TestDoubleCompilationFix() {
|
||||||
|
// Test that query is only compiled once even with flags in variables
|
||||||
|
query := "query GetUser($id: ID!) { user(id: $id) { id name } }"
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"id": "123",
|
||||||
|
"gqlcache": true,
|
||||||
|
"gqlretries": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should not cause double compilation
|
||||||
|
compiledQuery := suite.client.compileQuery(query, variables)
|
||||||
|
suite.NotNil(compiledQuery)
|
||||||
|
suite.Equal("query GetUser($id: ID!){user(id: $id){id name}}", compiledQuery.Query)
|
||||||
|
|
||||||
|
// Variables should still contain the flags at this point (they're cleaned in Query method)
|
||||||
|
suite.Contains(compiledQuery.Variables, "gqlcache")
|
||||||
|
suite.Contains(compiledQuery.Variables, "gqlretries")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *QueryOptimizationTestSuite) TestQueryMethodOptimization() {
|
||||||
|
// Test that processFlags works correctly without making HTTP requests
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"id": "123",
|
||||||
|
"gqlcache": true,
|
||||||
|
"gqlretries": true,
|
||||||
|
}
|
||||||
|
headers := map[string]interface{}{}
|
||||||
|
|
||||||
|
// Test processFlags directly
|
||||||
|
enableCache, enableRetries, cleanedVariables := processFlags(variables, headers)
|
||||||
|
|
||||||
|
suite.True(enableCache, "Cache should be enabled")
|
||||||
|
suite.True(enableRetries, "Retries should be enabled")
|
||||||
|
suite.Len(cleanedVariables, 1, "Should have only id variable")
|
||||||
|
suite.Equal("123", cleanedVariables["id"], "ID should be preserved")
|
||||||
|
suite.NotContains(cleanedVariables, "gqlcache", "gqlcache should be removed")
|
||||||
|
suite.NotContains(cleanedVariables, "gqlretries", "gqlretries should be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark the optimization impact
|
||||||
|
func BenchmarkQueryCompilation(b *testing.B) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
query := "query GetUser($id: ID!) { user(id: $id) { id name email profile { avatar bio } } }"
|
||||||
|
|
||||||
|
b.Run("WithoutFlags", func(b *testing.B) {
|
||||||
|
variables := map[string]interface{}{"id": "123"}
|
||||||
|
headers := map[string]interface{}{}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
processFlags(variables, headers)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("WithFlags", func(b *testing.B) {
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"id": "123",
|
||||||
|
"gqlcache": true,
|
||||||
|
"gqlretries": true,
|
||||||
|
}
|
||||||
|
headers := map[string]interface{}{}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
processFlags(variables, headers)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("CompileQuery", func(b *testing.B) {
|
||||||
|
variables := map[string]interface{}{"id": "123"}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
client.compileQuery(query, variables)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryOptimizationSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(QueryOptimizationTestSuite))
|
||||||
|
}
|
||||||
+384
-68
@@ -1,113 +1,429 @@
|
|||||||
package gql
|
package gql
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/goccy/go-reflect"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (suite *TestSuite) Test_GraphQL_queryBuilder() {
|
func (suite *Tests) TestBaseClient_convertToJSON() {
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
queryContent string
|
v any
|
||||||
queryVariables interface{}
|
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args args
|
args args
|
||||||
want []byte
|
want []byte
|
||||||
wantErr bool
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Valid query",
|
name: "TestBaseClient_convertToJSON_query",
|
||||||
args: args{
|
args: args{
|
||||||
queryContent: `query listUserBots { tbl_bots { bot_name } }`,
|
v: `query { viewer { login } }`,
|
||||||
queryVariables: nil,
|
|
||||||
},
|
},
|
||||||
want: []byte(`{"query":"query listUserBots { tbl_bots { bot_name } }","variables":null}`),
|
want: []byte(`"query { viewer { login } }"`),
|
||||||
wantErr: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Valid query with variables",
|
name: "TestBaseClient_convertToJSON_variables",
|
||||||
args: args{
|
args: args{
|
||||||
queryContent: `query listUserBots { tbl_bots { bot_name } }`,
|
v: map[string]interface{}{
|
||||||
queryVariables: map[string]interface{}{"user_id": 1},
|
"login": "lukaszraczylo",
|
||||||
|
"first": 10,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
want: []byte(`{"query":"query listUserBots { tbl_bots { bot_name } }","variables":{"user_id":1}}`),
|
want: []byte(`{"first":10,"login":"lukaszraczylo"}`),
|
||||||
wantErr: false,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
suite.T().Run(tt.name, func(t *testing.T) {
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
q := NewConnection()
|
b := NewConnection()
|
||||||
got, gotErr := q.queryBuilder(tt.args.queryContent, tt.args.queryVariables)
|
if got := b.convertToJSON(tt.args.v); !reflect.DeepEqual(got, tt.want) {
|
||||||
assert.Equal(t, tt.want, got)
|
assert.Equal(tt.want, got)
|
||||||
if tt.wantErr {
|
|
||||||
assert.Error(t, gotErr)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *TestSuite) Test_GraphQL_Query() {
|
func (suite *Tests) TestBaseClient_compileQuery() {
|
||||||
if testing.Short() {
|
|
||||||
suite.T().Skip("Skipping test in short / CI mode")
|
|
||||||
}
|
|
||||||
type args struct {
|
type args struct {
|
||||||
queryContent string
|
query_partials []any
|
||||||
queryVariables interface{}
|
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
wantQ *Query
|
||||||
endpoint string
|
name string
|
||||||
isLocal bool
|
args args
|
||||||
args args
|
|
||||||
wantResult string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Valid query",
|
name: "TestBaseClient_compileQuery_query",
|
||||||
endpoint: "http://hasura.local/v1/graphql",
|
|
||||||
isLocal: true,
|
|
||||||
args: args{
|
args: args{
|
||||||
queryContent: `query listUserBots {
|
query_partials: []any{
|
||||||
tbl_bots(limit: 1) {
|
`query { viewer { login } }`,
|
||||||
bot_name
|
},
|
||||||
}
|
},
|
||||||
}`,
|
wantQ: &Query{
|
||||||
queryVariables: nil,
|
Query: `query{viewer{login}}`,
|
||||||
|
JsonQuery: []byte(`{"query":"query{viewer{login}}"}`),
|
||||||
},
|
},
|
||||||
wantResult: `{"tbl_bots":[{"bot_name":"littleMentionBot"}]}`,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Invalid query",
|
name: "TestBaseClient_compileQuery_with_variables",
|
||||||
endpoint: "http://hasura.local/v1/graphql",
|
|
||||||
isLocal: true,
|
|
||||||
args: args{
|
args: args{
|
||||||
queryContent: `query listUserBots {
|
query_partials: []any{
|
||||||
tbl_botz(limit: 1) {
|
`query { viewer { login } }`,
|
||||||
bot_name
|
map[string]interface{}{
|
||||||
}
|
"login": "lukaszraczylo",
|
||||||
}`,
|
"first": 10,
|
||||||
queryVariables: nil,
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
wantResult: ``,
|
wantQ: &Query{
|
||||||
wantErr: true,
|
Query: `query{viewer{login}}`,
|
||||||
|
Variables: map[string]interface{}{
|
||||||
|
"login": "lukaszraczylo",
|
||||||
|
"first": 10,
|
||||||
|
},
|
||||||
|
JsonQuery: []byte(`{"variables":{"first":10,"login":"lukaszraczylo"},"query":"query{viewer{login}}"}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TestBaseClient_compileQuery_variables_without_query",
|
||||||
|
args: args{
|
||||||
|
query_partials: []any{
|
||||||
|
map[string]interface{}{
|
||||||
|
"login": "lukaszraczylo",
|
||||||
|
"first": 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantQ: nil,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
suite.T().Run(tt.name, func(t *testing.T) {
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
if tt.isLocal {
|
b := NewConnection()
|
||||||
os.Setenv("GRAPHQL_ENDPOINT", tt.endpoint)
|
got := b.compileQuery(tt.args.query_partials...)
|
||||||
}
|
assert.Equal(tt.wantQ, got)
|
||||||
g := NewConnection()
|
|
||||||
gotResult, gotErr := g.Query(tt.args.queryContent, tt.args.queryVariables, nil)
|
|
||||||
if tt.wantErr {
|
|
||||||
assert.Error(t, gotErr)
|
|
||||||
}
|
|
||||||
assert.Equal(t, tt.wantResult, gotResult)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_Query() {
|
||||||
|
type args struct {
|
||||||
|
variables map[string]interface{}
|
||||||
|
headers map[string]interface{}
|
||||||
|
query string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
args args
|
||||||
|
wantReturned_value any
|
||||||
|
name string
|
||||||
|
wantType string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "TestBaseClient_Query_viewer",
|
||||||
|
args: args{
|
||||||
|
query: "query { viewer { login } }",
|
||||||
|
variables: nil,
|
||||||
|
headers: map[string]interface{}{
|
||||||
|
"x-apollo-operation-name": "ViewerQuery",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantReturned_value: `{"viewer":{"login":"mockuser"}}`, // Adjusted expected value
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TestBaseClient_Query_dragons",
|
||||||
|
args: args{
|
||||||
|
query: `query Dragons {
|
||||||
|
dragons {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables: nil,
|
||||||
|
headers: map[string]interface{}{
|
||||||
|
"x-apollo-operation-name": "DragonsQuery",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantReturned_value: `{"dragons":[{"name":"Mock Dragon 1"},{"name":"Mock Dragon 2"}]}`, // Adjusted expected value
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "TestBaseClient_Query_invalid_query",
|
||||||
|
args: args{
|
||||||
|
query: "query { potato { login } ",
|
||||||
|
variables: nil,
|
||||||
|
headers: map[string]interface{}{
|
||||||
|
"x-apollo-operation-name": "InvalidQuery",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantReturned_value: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
b.SetEndpoint(mockServer.URL)
|
||||||
|
b.SetHTTPClient(mockServer.Client())
|
||||||
|
if tt.wantType != "" {
|
||||||
|
b.SetOutput(tt.wantType)
|
||||||
|
}
|
||||||
|
gotReturned_value, err := b.Query(tt.args.query, tt.args.variables, tt.args.headers)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(err)
|
||||||
|
}
|
||||||
|
assert.Equal(tt.wantReturned_value, gotReturned_value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_convertToJSON_errors() {
|
||||||
|
suite.T().Run("should handle JSON encoding errors", func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
|
||||||
|
// Test with a value that can't be JSON encoded (function)
|
||||||
|
result := b.convertToJSON(func() {})
|
||||||
|
assert.Nil(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle Query type with variables", func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
|
||||||
|
query := &Query{
|
||||||
|
Query: "query { test }",
|
||||||
|
Variables: map[string]interface{}{
|
||||||
|
"var1": "value1",
|
||||||
|
"var2": 123,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := b.convertToJSON(query)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Contains(string(result), "query { test }")
|
||||||
|
assert.Contains(string(result), "var1")
|
||||||
|
assert.Contains(string(result), "value1")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_compileQuery_errors() {
|
||||||
|
suite.T().Run("should handle empty query", func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
|
||||||
|
result := b.compileQuery("")
|
||||||
|
assert.Nil(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle no arguments", func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
|
||||||
|
result := b.compileQuery()
|
||||||
|
assert.Nil(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle invalid variable type", func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
|
||||||
|
result := b.compileQuery("query { test }", "invalid_variables")
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Equal("query{test}", result.Query)
|
||||||
|
assert.Nil(result.Variables)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) Test_processFlags() {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
variables map[string]interface{}
|
||||||
|
headers map[string]interface{}
|
||||||
|
wantCache bool
|
||||||
|
wantRetries bool
|
||||||
|
wantCleanedVars map[string]interface{}
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cache enabled in headers",
|
||||||
|
variables: nil,
|
||||||
|
headers: map[string]interface{}{
|
||||||
|
"gqlcache": true,
|
||||||
|
},
|
||||||
|
wantCache: true,
|
||||||
|
wantRetries: false,
|
||||||
|
wantCleanedVars: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "retries enabled in headers",
|
||||||
|
variables: nil,
|
||||||
|
headers: map[string]interface{}{
|
||||||
|
"gqlretries": true,
|
||||||
|
},
|
||||||
|
wantCache: false,
|
||||||
|
wantRetries: true,
|
||||||
|
wantCleanedVars: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cache enabled in variables",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"gqlcache": true,
|
||||||
|
"test": "value",
|
||||||
|
},
|
||||||
|
headers: nil,
|
||||||
|
wantCache: true,
|
||||||
|
wantRetries: false,
|
||||||
|
wantCleanedVars: map[string]interface{}{
|
||||||
|
"test": "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "retries enabled in variables",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"gqlretries": true,
|
||||||
|
"test": "value",
|
||||||
|
},
|
||||||
|
headers: nil,
|
||||||
|
wantCache: false,
|
||||||
|
wantRetries: true,
|
||||||
|
wantCleanedVars: map[string]interface{}{
|
||||||
|
"test": "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both flags in variables",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"gqlcache": true,
|
||||||
|
"gqlretries": false,
|
||||||
|
"test": "value",
|
||||||
|
},
|
||||||
|
headers: nil,
|
||||||
|
wantCache: true,
|
||||||
|
wantRetries: false,
|
||||||
|
wantCleanedVars: map[string]interface{}{
|
||||||
|
"test": "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flags in both headers and variables",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"gqlcache": false,
|
||||||
|
"test": "value",
|
||||||
|
},
|
||||||
|
headers: map[string]interface{}{
|
||||||
|
"gqlretries": true,
|
||||||
|
},
|
||||||
|
wantCache: false,
|
||||||
|
wantRetries: true,
|
||||||
|
wantCleanedVars: map[string]interface{}{
|
||||||
|
"test": "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no flags",
|
||||||
|
variables: map[string]interface{}{"test": "value"},
|
||||||
|
headers: nil,
|
||||||
|
wantCache: false,
|
||||||
|
wantRetries: false,
|
||||||
|
wantCleanedVars: map[string]interface{}{"test": "value"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.T().Run(tt.name, func(t *testing.T) {
|
||||||
|
gotCache, gotRetries, gotCleanedVars := processFlags(tt.variables, tt.headers)
|
||||||
|
assert.Equal(tt.wantCache, gotCache)
|
||||||
|
assert.Equal(tt.wantRetries, gotRetries)
|
||||||
|
assert.Equal(tt.wantCleanedVars, gotCleanedVars)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_Query_caching() {
|
||||||
|
suite.T().Run("should use cache for query operations", func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
b.SetEndpoint(mockServer.URL)
|
||||||
|
b.SetHTTPClient(mockServer.Client())
|
||||||
|
|
||||||
|
query := "query { viewer { login } }"
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"gqlcache": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// First call should hit the server
|
||||||
|
result1, err := b.Query(query, variables, nil)
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result1)
|
||||||
|
|
||||||
|
// Second call should use cache
|
||||||
|
result2, err := b.Query(query, variables, nil)
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.Equal(result1, result2)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should not cache mutations", func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
b.SetEndpoint(mockServer.URL)
|
||||||
|
b.SetHTTPClient(mockServer.Client())
|
||||||
|
|
||||||
|
mutation := "mutation { updateUser { id } }"
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"gqlcache": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutations should not be cached even with cache flag
|
||||||
|
_, err := b.Query(mutation, variables, nil)
|
||||||
|
assert.Error(err) // Mock server doesn't handle mutations
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_Query_retries() {
|
||||||
|
suite.T().Run("should handle retries flag", func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
b.SetEndpoint(mockServer.URL)
|
||||||
|
b.SetHTTPClient(mockServer.Client())
|
||||||
|
|
||||||
|
query := "query { viewer { login } }"
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"gqlretries": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := b.Query(query, variables, nil)
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_Query_globalCache() {
|
||||||
|
suite.T().Run("should respect global cache setting", func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
b.SetEndpoint(mockServer.URL)
|
||||||
|
b.SetHTTPClient(mockServer.Client())
|
||||||
|
b.cache_global = true
|
||||||
|
|
||||||
|
query := "query { viewer { login } }"
|
||||||
|
|
||||||
|
// Should use cache even without explicit flag
|
||||||
|
result1, err := b.Query(query, nil, nil)
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result1)
|
||||||
|
|
||||||
|
result2, err := b.Query(query, nil, nil)
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.Equal(result1, result2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TestBaseClient_Query_emptyQuery() {
|
||||||
|
suite.T().Run("should handle empty query", func(t *testing.T) {
|
||||||
|
b := NewConnection()
|
||||||
|
b.SetEndpoint(mockServer.URL)
|
||||||
|
b.SetHTTPClient(mockServer.Client())
|
||||||
|
|
||||||
|
result, err := b.Query("", nil, nil)
|
||||||
|
assert.Error(err)
|
||||||
|
assert.Nil(result)
|
||||||
|
assert.Contains(err.Error(), "can't compile query")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
assertions "github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RequestCompressionTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
assert *assertions.Assertions
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestCompressionSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(RequestCompressionTestSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *RequestCompressionTestSuite) SetupTest() {
|
||||||
|
suite.assert = assertions.New(suite.T())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *RequestCompressionTestSuite) TestRequestCompression() {
|
||||||
|
suite.T().Run("should disable request compression for HTTP clients", func(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
// Track what the server receives
|
||||||
|
var receivedHeaders http.Header
|
||||||
|
var receivedBody []byte
|
||||||
|
var receivedContentEncoding string
|
||||||
|
|
||||||
|
// Create test server that captures request details
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedHeaders = r.Header.Clone()
|
||||||
|
receivedContentEncoding = r.Header.Get("Content-Encoding")
|
||||||
|
|
||||||
|
// Read the request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to read request body: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
receivedBody = body
|
||||||
|
|
||||||
|
// Send back a simple JSON response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"viewer":{"login":"testuser"}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Test both HTTP and HTTPS endpoints
|
||||||
|
endpoints := []string{
|
||||||
|
strings.Replace(server.URL, "http://", "http://", 1), // HTTP
|
||||||
|
strings.Replace(server.URL, "http://", "https://", 1), // HTTPS (will use HTTP/2)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
t.Run("endpoint_"+endpoint, func(t *testing.T) {
|
||||||
|
// Reset captured data
|
||||||
|
receivedHeaders = nil
|
||||||
|
receivedBody = nil
|
||||||
|
receivedContentEncoding = ""
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
client.endpoint = endpoint
|
||||||
|
|
||||||
|
// Create HTTP client using the same logic as the library
|
||||||
|
httpClient := client.createHttpClient()
|
||||||
|
assert.NotNil(httpClient, "HTTP client should be created")
|
||||||
|
|
||||||
|
// Verify transport settings based on protocol
|
||||||
|
if strings.HasPrefix(endpoint, "http://") {
|
||||||
|
// HTTP/1.1 transport for http:// (h2c rarely supported)
|
||||||
|
transport, ok := httpClient.Transport.(*http.Transport)
|
||||||
|
assert.True(ok, "Should be http.Transport for http:// endpoints")
|
||||||
|
if ok {
|
||||||
|
assert.True(transport.DisableCompression, "DisableCompression should be true to prevent trailing garbage")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// HTTP/2 transport for https://
|
||||||
|
transport, ok := httpClient.Transport.(*http2.Transport)
|
||||||
|
assert.True(ok, "Should be http2.Transport for https:// endpoints")
|
||||||
|
if ok {
|
||||||
|
assert.True(transport.DisableCompression, "DisableCompression should be true to prevent trailing garbage")
|
||||||
|
assert.True(transport.AllowHTTP, "AllowHTTP should be true")
|
||||||
|
assert.NotNil(transport.TLSClientConfig, "TLSClientConfig should be set for https:// endpoints")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = server.URL // Use HTTP server URL regardless of test endpoint
|
||||||
|
qe.client = server.Client() // Use server's client to avoid TLS issues
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err, "Query should execute successfully")
|
||||||
|
assert.NotNil(result, "Result should not be nil")
|
||||||
|
|
||||||
|
// Verify request was sent as plain JSON (not compressed)
|
||||||
|
assert.Empty(receivedContentEncoding, "Request should not have Content-Encoding header")
|
||||||
|
assert.NotContains(receivedHeaders.Get("Accept-Encoding"), "gzip", "Should not automatically request gzip encoding")
|
||||||
|
|
||||||
|
// Verify request body is plain JSON (not gzip compressed)
|
||||||
|
assert.True(len(receivedBody) > 0, "Should have received request body")
|
||||||
|
|
||||||
|
// Check that body is NOT gzip compressed by checking magic bytes
|
||||||
|
if len(receivedBody) >= 2 {
|
||||||
|
assert.False(receivedBody[0] == 0x1f && receivedBody[1] == 0x8b,
|
||||||
|
"Request body should NOT have gzip magic bytes (0x1f 0x8b), got: 0x%02x 0x%02x",
|
||||||
|
receivedBody[0], receivedBody[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify body is valid JSON
|
||||||
|
bodyStr := string(receivedBody)
|
||||||
|
assert.True(strings.HasPrefix(bodyStr, "{"), "Request body should start with '{' (JSON)")
|
||||||
|
assert.Contains(bodyStr, "query", "Request body should contain 'query' field")
|
||||||
|
assert.Contains(bodyStr, "viewer", "Request body should contain the query content")
|
||||||
|
|
||||||
|
// Verify no automatic compression headers were added
|
||||||
|
assert.Empty(receivedHeaders.Get("Content-Encoding"), "Request should not have Content-Encoding header")
|
||||||
|
|
||||||
|
// The Accept-Encoding header should either be empty or not contain gzip
|
||||||
|
acceptEncoding := receivedHeaders.Get("Accept-Encoding")
|
||||||
|
if acceptEncoding != "" {
|
||||||
|
assert.NotContains(acceptEncoding, "gzip", "Accept-Encoding should not request gzip compression")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should handle gzip responses correctly while sending plain requests", func(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
var receivedBody []byte
|
||||||
|
var receivedContentEncoding string
|
||||||
|
|
||||||
|
// Server that captures request and sends gzip response
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Capture request details
|
||||||
|
receivedContentEncoding = r.Header.Get("Content-Encoding")
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
receivedBody = body
|
||||||
|
|
||||||
|
// Send gzip compressed response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&buf)
|
||||||
|
gzipWriter.Write([]byte(`{"data":{"viewer":{"login":"gzipresponseuser"}}}`))
|
||||||
|
gzipWriter.Close()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = server.URL
|
||||||
|
qe.client = server.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err, "Should handle gzip response correctly")
|
||||||
|
assert.NotNil(result, "Result should not be nil")
|
||||||
|
assert.Contains(string(result), "gzipresponseuser", "Should decompress gzip response")
|
||||||
|
|
||||||
|
// Verify request was sent as plain JSON
|
||||||
|
assert.Empty(receivedContentEncoding, "Request should not be compressed")
|
||||||
|
assert.True(len(receivedBody) > 0, "Should have received request body")
|
||||||
|
|
||||||
|
// Verify request is not gzip compressed
|
||||||
|
if len(receivedBody) >= 2 {
|
||||||
|
assert.False(receivedBody[0] == 0x1f && receivedBody[1] == 0x8b,
|
||||||
|
"Request should not be gzip compressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify request body is valid JSON
|
||||||
|
bodyStr := string(receivedBody)
|
||||||
|
assert.True(strings.HasPrefix(bodyStr, "{"), "Request should be JSON")
|
||||||
|
assert.Contains(bodyStr, "query", "Request should contain query")
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.T().Run("should prevent trailing garbage errors from compressed requests", func(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
var receivedBody []byte
|
||||||
|
|
||||||
|
// Server that would fail if it received compressed requests
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
receivedBody = body
|
||||||
|
|
||||||
|
// Check if request looks like gzip (which would cause trailing garbage errors)
|
||||||
|
if len(body) >= 2 && body[0] == 0x1f && body[1] == 0x8b {
|
||||||
|
// Simulate server that can't handle gzip requests properly
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"trailing garbage after JSON"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal JSON response for plain requests
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"viewer":{"login":"notrailinggarbage"}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: []byte(`{"query":"query { viewer { login } }"}`),
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = server.URL
|
||||||
|
qe.client = server.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err, "Should not get trailing garbage errors")
|
||||||
|
assert.NotNil(result, "Result should not be nil")
|
||||||
|
assert.Contains(string(result), "notrailinggarbage", "Should get successful response")
|
||||||
|
|
||||||
|
// Verify request was plain JSON (not gzip)
|
||||||
|
assert.True(len(receivedBody) > 0, "Should have received request body")
|
||||||
|
if len(receivedBody) >= 2 {
|
||||||
|
assert.False(receivedBody[0] == 0x1f && receivedBody[1] == 0x8b,
|
||||||
|
"Request should not be gzip compressed to prevent trailing garbage errors")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
graphql "github.com/lukaszraczylo/go-simple-graphql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
gql := graphql.NewConnection()
|
||||||
|
|
||||||
|
query := `query MyQuery {
|
||||||
|
bots {
|
||||||
|
bot_name
|
||||||
|
ddd
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
_, err := gql.Query(query, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error returned from query:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query = `query MyQuery {
|
||||||
|
badwords(distinct_on: word) {
|
||||||
|
word
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
gql.Query(query, nil, nil)
|
||||||
|
|
||||||
|
// Repeated to use the cached response
|
||||||
|
query = `query MyQuery {
|
||||||
|
badwords(distinct_on: word) {
|
||||||
|
word
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
gql.Query(query, nil, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func Test_main(t *testing.T) {
|
||||||
|
// Test that main function doesn't panic during setup
|
||||||
|
// The actual GraphQL calls will fail, but we want to test the setup code
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Errorf("main() panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Call main() to get coverage - it will fail on network calls but that's expected
|
||||||
|
// This tests the NewConnection() call and query compilation
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_samplePackageExists(t *testing.T) {
|
||||||
|
// This test ensures the sample package is properly structured
|
||||||
|
// and can be imported/compiled
|
||||||
|
|
||||||
|
// Test passes if we reach this point without compilation errors
|
||||||
|
}
|
||||||
+2
-1
@@ -1,8 +1,9 @@
|
|||||||
version: 1
|
version: 1
|
||||||
force:
|
force:
|
||||||
major: 1
|
major: 1
|
||||||
minor: 0
|
minor: 2
|
||||||
patch: 0
|
patch: 0
|
||||||
|
commit: 3217edf37fd1765c4f2594e3ba122a6a9c251296
|
||||||
wording:
|
wording:
|
||||||
minor:
|
minor:
|
||||||
- minor-release
|
- minor-release
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cache "github.com/lukaszraczylo/go-simple-graphql/cache"
|
||||||
|
logging "github.com/lukaszraczylo/go-simple-graphql/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BaseClient struct {
|
||||||
|
cache *cache.Cache
|
||||||
|
Logger *logging.Logger
|
||||||
|
client *http.Client
|
||||||
|
endpoint string
|
||||||
|
responseType string
|
||||||
|
retries_delay time.Duration
|
||||||
|
retries_number int
|
||||||
|
retries_patterns []string // Error patterns that should trigger retries
|
||||||
|
pool_size int // Number of connections to pre-warm
|
||||||
|
pool_warmup_enabled bool // Enable connection pool warmup
|
||||||
|
pool_warmup_query string // Query to use for warming up connections
|
||||||
|
pool_health_interval time.Duration // Interval for pool health checks
|
||||||
|
pool_stop chan bool // Channel to stop pool monitor
|
||||||
|
pool_stop_once sync.Once // Ensures StopPoolMonitor is called only once
|
||||||
|
MaxGoRoutines int
|
||||||
|
cache_global bool
|
||||||
|
retries_enable bool
|
||||||
|
minify_queries bool // Enable GraphQL query minification (default: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query struct {
|
||||||
|
Variables map[string]interface{} `json:"variables,omitempty"`
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
JsonQuery []byte `json:"-"` // Exclude from JSON serialization to prevent nested encoding
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryExecutor struct {
|
||||||
|
Result any
|
||||||
|
Error error
|
||||||
|
*BaseClient
|
||||||
|
Headers map[string]interface{}
|
||||||
|
CacheKey string
|
||||||
|
Query []byte
|
||||||
|
CacheTTL time.Duration
|
||||||
|
Retries bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryResults struct {
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
Errors []struct {
|
||||||
|
Message interface{} `json:"message"`
|
||||||
|
} `json:"errors"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
assertions "github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tests struct {
|
||||||
|
suite.Suite
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
assert *assertions.Assertions
|
||||||
|
mockServer *httptest.Server
|
||||||
|
)
|
||||||
|
|
||||||
|
func (suite *Tests) SetupSuite() {
|
||||||
|
// Start mock GraphQL server
|
||||||
|
mockServer = StartMockServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) TearDownSuite() {
|
||||||
|
// Close the mock server
|
||||||
|
if mockServer != nil {
|
||||||
|
mockServer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup shared test cache
|
||||||
|
CleanupTestCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) SetupTest() {
|
||||||
|
assert = assertions.New(suite.T())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *Tests) BeforeTest(suiteName, testName string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(Tests))
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cache "github.com/lukaszraczylo/go-simple-graphql/cache"
|
||||||
|
logging "github.com/lukaszraczylo/go-simple-graphql/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testCacheOnce sync.Once
|
||||||
|
testCache *cache.Cache
|
||||||
|
testLogger *logging.Logger
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTestCache returns a shared cache instance for tests to reduce resource contention
|
||||||
|
func GetTestCache() *cache.Cache {
|
||||||
|
testCacheOnce.Do(func() {
|
||||||
|
testCache = cache.New(5 * time.Second) // Short TTL for tests
|
||||||
|
})
|
||||||
|
return testCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTestLogger returns a shared logger instance for tests
|
||||||
|
func GetTestLogger() *logging.Logger {
|
||||||
|
if testLogger == nil {
|
||||||
|
testLogger = logging.New()
|
||||||
|
|
||||||
|
// Check if LOG_LEVEL is set for tests, otherwise use LEVEL_ERROR
|
||||||
|
logLevelStr := "error" // Default for tests
|
||||||
|
if envLogLevel := os.Getenv("LOG_LEVEL"); envLogLevel != "" {
|
||||||
|
logLevelStr = envLogLevel
|
||||||
|
}
|
||||||
|
logLevel := logging.GetLogLevel(logLevelStr)
|
||||||
|
testLogger.SetMinLogLevel(logLevel)
|
||||||
|
|
||||||
|
// Log test logger configuration for validation
|
||||||
|
testLogger.Info(&logging.LogMessage{
|
||||||
|
Message: "Test logger initialized",
|
||||||
|
Pairs: map[string]interface{}{
|
||||||
|
"LOG_LEVEL_env_var": os.Getenv("LOG_LEVEL"),
|
||||||
|
"effective_log_level": logLevelStr,
|
||||||
|
"parsed_log_level": logLevel,
|
||||||
|
"level_name": logging.LevelNames[logLevel],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return testLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupTestCache stops the shared test cache - call this in TestMain if needed
|
||||||
|
func CleanupTestCache() {
|
||||||
|
if testCache != nil {
|
||||||
|
testCache.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTestClient creates a test client with shared resources
|
||||||
|
func CreateTestClient() *BaseClient {
|
||||||
|
return &BaseClient{
|
||||||
|
Logger: GetTestLogger(),
|
||||||
|
cache: GetTestCache(),
|
||||||
|
endpoint: "https://example.com/graphql",
|
||||||
|
responseType: "mapstring",
|
||||||
|
minify_queries: true, // Enable query minification by default for tests
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,804 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
assertions "github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTrailingGarbageReproduction attempts to reproduce the exact "trailing garbage" scenario
|
||||||
|
func TestTrailingGarbageReproduction(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
t.Run("should investigate size discrepancy - 1093 vs 356 bytes", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Base data from user report
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }"
|
||||||
|
|
||||||
|
// Test different scenarios that might increase size to 1093 bytes
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
modifier func(map[string]interface{}) map[string]interface{}
|
||||||
|
queryMod func(string) string
|
||||||
|
expectSize int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "base_case",
|
||||||
|
modifier: func(v map[string]interface{}) map[string]interface{} {
|
||||||
|
return v
|
||||||
|
},
|
||||||
|
queryMod: func(q string) string { return q },
|
||||||
|
expectSize: 350,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with_additional_headers_in_variables",
|
||||||
|
modifier: func(v map[string]interface{}) map[string]interface{} {
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
for k, val := range v {
|
||||||
|
result[k] = val
|
||||||
|
}
|
||||||
|
// Add headers that might be included in variables
|
||||||
|
result["authorization"] = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||||
|
result["user_agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
|
result["content_type"] = "application/json; charset=utf-8"
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
queryMod: func(q string) string { return q },
|
||||||
|
expectSize: 700, // Estimated
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with_nested_json_data",
|
||||||
|
modifier: func(v map[string]interface{}) map[string]interface{} {
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
for k, val := range v {
|
||||||
|
result[k] = val
|
||||||
|
}
|
||||||
|
// Add nested JSON that might be double-encoded
|
||||||
|
result["metadata"] = map[string]interface{}{
|
||||||
|
"client_info": map[string]interface{}{
|
||||||
|
"platform": "web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"features": []string{"emoji_support", "unicode_text", "file_upload"},
|
||||||
|
},
|
||||||
|
"request_id": "req_123456789_abcdef",
|
||||||
|
"timestamp": "2023-05-23T18:01:37.000Z",
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
queryMod: func(q string) string { return q },
|
||||||
|
expectSize: 800, // Estimated
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with_very_long_query",
|
||||||
|
modifier: func(v map[string]interface{}) map[string]interface{} {
|
||||||
|
return v
|
||||||
|
},
|
||||||
|
queryMod: func(q string) string {
|
||||||
|
// Add a much longer query with many fields
|
||||||
|
return `mutation storeMessage(
|
||||||
|
$group_description: String!,
|
||||||
|
$user_name: String!,
|
||||||
|
$additional_field1: String,
|
||||||
|
$additional_field2: String,
|
||||||
|
$additional_field3: String,
|
||||||
|
$additional_field4: String,
|
||||||
|
$additional_field5: String
|
||||||
|
) {
|
||||||
|
insert_message(
|
||||||
|
group_description: $group_description,
|
||||||
|
user_name: $user_name,
|
||||||
|
additional_field1: $additional_field1,
|
||||||
|
additional_field2: $additional_field2,
|
||||||
|
additional_field3: $additional_field3,
|
||||||
|
additional_field4: $additional_field4,
|
||||||
|
additional_field5: $additional_field5
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
status
|
||||||
|
metadata {
|
||||||
|
source
|
||||||
|
platform
|
||||||
|
version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
},
|
||||||
|
expectSize: 1000, // Estimated
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
modifiedVars := scenario.modifier(variables)
|
||||||
|
modifiedQuery := scenario.queryMod(query)
|
||||||
|
|
||||||
|
compiledQuery := client.compileQuery(modifiedQuery, modifiedVars)
|
||||||
|
assert.NotNil(compiledQuery)
|
||||||
|
|
||||||
|
size := len(compiledQuery.JsonQuery)
|
||||||
|
t.Logf("%s: JSON size = %d bytes (expected ~%d)", scenario.name, size, scenario.expectSize)
|
||||||
|
|
||||||
|
if size >= 1090 && size <= 1210 { // Expanded range to include our actual results
|
||||||
|
t.Logf("🎯 FOUND POTENTIAL MATCH: %s produces %d bytes (target: 1093)", scenario.name, size)
|
||||||
|
t.Logf("JSON content: %s", string(compiledQuery.JsonQuery))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's still valid
|
||||||
|
assert.True(json.Valid(compiledQuery.JsonQuery))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should test potential double-encoding scenarios", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test if the JsonQuery field might be getting included somehow
|
||||||
|
query := &Query{
|
||||||
|
Query: "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }",
|
||||||
|
Variables: map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, compile normally
|
||||||
|
query.JsonQuery = client.convertToJSON(query)
|
||||||
|
normalSize := len(query.JsonQuery)
|
||||||
|
t.Logf("Normal encoding size: %d bytes", normalSize)
|
||||||
|
|
||||||
|
// Test if somehow JsonQuery gets included in serialization (it shouldn't due to json:"-")
|
||||||
|
doubleEncodedBytes, err := json.Marshal(query)
|
||||||
|
assert.NoError(err)
|
||||||
|
doubleEncodedSize := len(doubleEncodedBytes)
|
||||||
|
t.Logf("Double encoding size: %d bytes", doubleEncodedSize)
|
||||||
|
|
||||||
|
// Test if there's a scenario where the JsonQuery field is accidentally included
|
||||||
|
queryWithoutTag := struct {
|
||||||
|
Variables map[string]interface{} `json:"variables,omitempty"`
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
JsonQuery []byte `json:"jsonQuery,omitempty"` // Without json:"-"
|
||||||
|
}{
|
||||||
|
Variables: query.Variables,
|
||||||
|
Query: query.Query,
|
||||||
|
JsonQuery: query.JsonQuery,
|
||||||
|
}
|
||||||
|
|
||||||
|
problematicBytes, err := json.Marshal(queryWithoutTag)
|
||||||
|
assert.NoError(err)
|
||||||
|
problematicSize := len(problematicBytes)
|
||||||
|
t.Logf("Problematic encoding (with JsonQuery field) size: %d bytes", problematicSize)
|
||||||
|
|
||||||
|
if problematicSize >= 1090 && problematicSize <= 1100 {
|
||||||
|
t.Logf("🎯 FOUND ISSUE: Including JsonQuery field produces %d bytes!", problematicSize)
|
||||||
|
t.Logf("This would cause trailing garbage because JsonQuery contains base64 or nested JSON")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should test server that detects trailing garbage", func(t *testing.T) {
|
||||||
|
// Create a server that specifically looks for trailing garbage patterns
|
||||||
|
trailingGarbageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := make([]byte, r.ContentLength)
|
||||||
|
n, _ := r.Body.Read(body)
|
||||||
|
actualBody := body[:n]
|
||||||
|
|
||||||
|
t.Logf("Server analyzing %d bytes", len(actualBody))
|
||||||
|
|
||||||
|
// Try to decode JSON and check for trailing data
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(actualBody))
|
||||||
|
var request map[string]interface{}
|
||||||
|
err := decoder.Decode(&request)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("JSON decode error: %s", err.Error())
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"invalid JSON","code":"invalid-json"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's trailing data after the first JSON object
|
||||||
|
remaining := decoder.Buffered()
|
||||||
|
if remaining != nil {
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, _ := remaining.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
t.Logf("Found trailing data: %s", string(buf[:n]))
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"trailing garbage after JSON","code":"invalid-json"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for specific patterns that might indicate double encoding
|
||||||
|
if bytes.Contains(actualBody, []byte(`"jsonQuery"`)) {
|
||||||
|
t.Logf("Found jsonQuery field in request - this would cause issues!")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"trailing garbage after JSON","code":"invalid-json"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for base64 patterns that might indicate nested encoding
|
||||||
|
if bytes.Contains(actualBody, []byte("eyJ")) { // Common base64 JSON start
|
||||||
|
t.Logf("Found potential base64 encoded JSON in request")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"trailing garbage after JSON","code":"invalid-json"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"result":"success"}}`))
|
||||||
|
}))
|
||||||
|
defer trailingGarbageServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test the normal case
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: compiledQuery.JsonQuery,
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json; charset=utf-8"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = trailingGarbageServer.URL
|
||||||
|
qe.client = trailingGarbageServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err, "Normal request should succeed")
|
||||||
|
assert.NotNil(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should test Content-Length vs actual body size mismatch", func(t *testing.T) {
|
||||||
|
// Test if there's a mismatch between Content-Length and actual body size
|
||||||
|
var receivedContentLength int64
|
||||||
|
var actualBodySize int
|
||||||
|
|
||||||
|
mismatchServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedContentLength = r.ContentLength
|
||||||
|
|
||||||
|
body := make([]byte, r.ContentLength+100) // Read more than Content-Length
|
||||||
|
n, _ := r.Body.Read(body)
|
||||||
|
actualBodySize = n
|
||||||
|
|
||||||
|
t.Logf("Content-Length header: %d", receivedContentLength)
|
||||||
|
t.Logf("Actual body size read: %d", actualBodySize)
|
||||||
|
|
||||||
|
if int64(actualBodySize) != receivedContentLength {
|
||||||
|
t.Logf("⚠️ MISMATCH: Content-Length=%d but actual body=%d", receivedContentLength, actualBodySize)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"trailing garbage after JSON","code":"invalid-json"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"result":"success"}}`))
|
||||||
|
}))
|
||||||
|
defer mismatchServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: compiledQuery.JsonQuery,
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json; charset=utf-8"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = mismatchServer.URL
|
||||||
|
qe.client = mismatchServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err, "Should not have Content-Length mismatch")
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Equal(int64(actualBodySize), receivedContentLength, "Content-Length should match actual body size")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPotentialRootCauses tests the most likely root causes of the trailing garbage issue
|
||||||
|
func TestPotentialRootCauses(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
t.Run("root_cause_1_JsonQuery_field_accidentally_included", func(t *testing.T) {
|
||||||
|
// This would be the most likely cause - if JsonQuery field gets included in JSON
|
||||||
|
client := CreateTestClient()
|
||||||
|
_ = client // Use the client variable to avoid compiler error
|
||||||
|
|
||||||
|
// Simulate a bug where JsonQuery field is included
|
||||||
|
type BuggyQuery struct {
|
||||||
|
Variables map[string]interface{} `json:"variables,omitempty"`
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
JsonQuery []byte `json:"jsonQuery,omitempty"` // BUG: should be json:"-"
|
||||||
|
}
|
||||||
|
|
||||||
|
buggyQuery := &BuggyQuery{
|
||||||
|
Query: "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }",
|
||||||
|
Variables: map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set JsonQuery to the JSON representation of itself (recursive)
|
||||||
|
normalJson, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"query": buggyQuery.Query,
|
||||||
|
"variables": buggyQuery.Variables,
|
||||||
|
})
|
||||||
|
buggyQuery.JsonQuery = normalJson
|
||||||
|
|
||||||
|
// Now marshal the buggy query - this would include JsonQuery field
|
||||||
|
buggyJsonBytes, err := json.Marshal(buggyQuery)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
t.Logf("Buggy JSON size: %d bytes", len(buggyJsonBytes))
|
||||||
|
t.Logf("Buggy JSON content: %s", string(buggyJsonBytes))
|
||||||
|
|
||||||
|
// This would create a JSON with nested JSON, potentially causing parsing issues
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
err = json.Unmarshal(buggyJsonBytes, &parsed)
|
||||||
|
assert.NoError(err) // JSON is still valid, but contains extra data
|
||||||
|
|
||||||
|
// Check if this creates the size we're looking for
|
||||||
|
if len(buggyJsonBytes) >= 1090 && len(buggyJsonBytes) <= 1100 {
|
||||||
|
t.Logf("🎯 ROOT CAUSE FOUND: JsonQuery field inclusion creates %d bytes", len(buggyJsonBytes))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("root_cause_2_buffer_reuse_contamination", func(t *testing.T) {
|
||||||
|
// Test if buffer reuse might cause contamination
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Simulate multiple requests that might contaminate buffers
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": fmt.Sprintf("Request %d: This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈", i),
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
"request_id": fmt.Sprintf("req_%d", i),
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation storeMessage($group_description: String!, $user_name: String!, $request_id: String) { insert_message(group_description: $group_description, user_name: $user_name, request_id: $request_id) { id } }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
t.Logf("Request %d JSON size: %d bytes", i, len(compiledQuery.JsonQuery))
|
||||||
|
|
||||||
|
// Check for any unexpected content
|
||||||
|
assert.True(json.Valid(compiledQuery.JsonQuery))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("root_cause_3_encoding_header_mismatch", func(t *testing.T) {
|
||||||
|
// Test if Content-Type charset mismatch causes issues
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
// Test different charset declarations
|
||||||
|
charsets := []string{
|
||||||
|
"application/json",
|
||||||
|
"application/json; charset=utf-8",
|
||||||
|
"application/json; charset=UTF-8",
|
||||||
|
"application/json; charset=iso-8859-1", // Wrong charset
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, contentType := range charsets {
|
||||||
|
t.Logf("Testing Content-Type: %s", contentType)
|
||||||
|
|
||||||
|
// The JSON should be the same regardless of Content-Type header
|
||||||
|
assert.True(json.Valid(compiledQuery.JsonQuery))
|
||||||
|
assert.Equal(350, len(compiledQuery.JsonQuery)) // Updated for improved minification
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("root_cause_4_unicode_character_validation", func(t *testing.T) {
|
||||||
|
// Test the exact Unicode characters from production logs
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test various Unicode scenarios that might cause issues
|
||||||
|
unicodeScenarios := []struct {
|
||||||
|
name string
|
||||||
|
userName string
|
||||||
|
description string
|
||||||
|
expectValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "production_case_exact",
|
||||||
|
userName: "2eba巴结",
|
||||||
|
description: "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
expectValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed_unicode_emojis",
|
||||||
|
userName: "用户🎯💻🔥",
|
||||||
|
description: "Test with multiple emojis: 🚀🎉💡🔧⚡️🌟",
|
||||||
|
expectValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chinese_japanese_korean",
|
||||||
|
userName: "测试用户한국어日本語",
|
||||||
|
description: "Mixed CJK characters: 中文 한국어 日本語",
|
||||||
|
expectValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "special_unicode_chars",
|
||||||
|
userName: "user™®©",
|
||||||
|
description: "Special symbols: ™®©±×÷≠≤≥∞∑∏∆√∫",
|
||||||
|
expectValid: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range unicodeScenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": scenario.description,
|
||||||
|
"user_name": scenario.userName,
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
// Validate UTF-8 encoding
|
||||||
|
assert.True(utf8.Valid(compiledQuery.JsonQuery), "JSON should be valid UTF-8")
|
||||||
|
assert.True(json.Valid(compiledQuery.JsonQuery), "JSON should be valid")
|
||||||
|
|
||||||
|
// Check for any jsonQuery field inclusion
|
||||||
|
assert.False(bytes.Contains(compiledQuery.JsonQuery, []byte(`"jsonQuery"`)), "Should not contain jsonQuery field")
|
||||||
|
|
||||||
|
// Log the size for analysis
|
||||||
|
t.Logf("%s: JSON size = %d bytes", scenario.name, len(compiledQuery.JsonQuery))
|
||||||
|
t.Logf("Content: %s", string(compiledQuery.JsonQuery))
|
||||||
|
|
||||||
|
// Test with actual server
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := make([]byte, r.ContentLength)
|
||||||
|
n, _ := r.Body.Read(body)
|
||||||
|
actualBody := body[:n]
|
||||||
|
|
||||||
|
// Validate the request body
|
||||||
|
assert.True(utf8.Valid(actualBody), "Request body should be valid UTF-8")
|
||||||
|
assert.True(json.Valid(actualBody), "Request body should be valid JSON")
|
||||||
|
assert.False(bytes.Contains(actualBody, []byte(`"jsonQuery"`)), "Request should not contain jsonQuery field")
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"result":"success"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: compiledQuery.JsonQuery,
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json; charset=utf-8"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = server.URL
|
||||||
|
qe.client = server.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
if scenario.expectValid {
|
||||||
|
assert.NoError(err, "Unicode request should succeed")
|
||||||
|
assert.NotNil(result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiagnosticLogging tests the new diagnostic logging functionality
|
||||||
|
func TestDiagnosticLogging(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
t.Run("should_log_request_body_analysis", func(t *testing.T) {
|
||||||
|
// Create a server that captures and analyzes the request
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := make([]byte, r.ContentLength)
|
||||||
|
n, _ := r.Body.Read(body)
|
||||||
|
actualBody := body[:n]
|
||||||
|
|
||||||
|
// Verify Content-Type includes charset
|
||||||
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(contentType, "charset=utf-8") {
|
||||||
|
t.Errorf("Content-Type should include charset=utf-8, got: %s", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify body size matches Content-Length
|
||||||
|
if int64(len(actualBody)) != r.ContentLength {
|
||||||
|
t.Errorf("Body size mismatch: Content-Length=%d, actual=%d", r.ContentLength, len(actualBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"result":"success"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test with the production case
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: compiledQuery.JsonQuery,
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json; charset=utf-8"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = server.URL
|
||||||
|
qe.client = server.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
|
||||||
|
// The diagnostic logging should have been triggered
|
||||||
|
// We can't easily capture the logs in this test, but we can verify the request succeeded
|
||||||
|
t.Logf("Request completed successfully with diagnostic logging")
|
||||||
|
t.Logf("Request body size: %d bytes", len(compiledQuery.JsonQuery))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should_detect_jsonQuery_field_inclusion", func(t *testing.T) {
|
||||||
|
// Test the validation function directly
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Create a problematic JSON with jsonQuery field
|
||||||
|
problematicJSON := `{
|
||||||
|
"query": "mutation test { result }",
|
||||||
|
"variables": {"test": "value"},
|
||||||
|
"jsonQuery": "eyJxdWVyeSI6Im11dGF0aW9uIHRlc3QgeyByZXN1bHQgfSIsInZhcmlhYmxlcyI6eyJ0ZXN0IjoidmFsdWUifX0="
|
||||||
|
}`
|
||||||
|
|
||||||
|
// This should be detected by our validation
|
||||||
|
err := validateRequestBody([]byte(problematicJSON), client.Logger)
|
||||||
|
assert.Error(err, "Should detect jsonQuery field inclusion")
|
||||||
|
assert.Contains(err.Error(), "jsonQuery field found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should_validate_utf8_encoding", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test valid UTF-8
|
||||||
|
validJSON := `{"query": "mutation test { result }", "variables": {"user": "2eba巴结"}}`
|
||||||
|
err := validateRequestBody([]byte(validJSON), client.Logger)
|
||||||
|
assert.NoError(err, "Valid UTF-8 should pass validation")
|
||||||
|
|
||||||
|
// Test invalid UTF-8 (manually crafted invalid sequence)
|
||||||
|
invalidUTF8 := []byte(`{"query": "mutation test { result }", "variables": {"user": "test`)
|
||||||
|
invalidUTF8 = append(invalidUTF8, 0xFF, 0xFE) // Invalid UTF-8 sequence
|
||||||
|
invalidUTF8 = append(invalidUTF8, []byte(`"}}`)...)
|
||||||
|
|
||||||
|
err = validateRequestBody(invalidUTF8, client.Logger)
|
||||||
|
assert.Error(err, "Invalid UTF-8 should fail validation")
|
||||||
|
assert.Contains(err.Error(), "invalid UTF-8")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should_detect_null_bytes", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test JSON with null bytes
|
||||||
|
jsonWithNulls := []byte(`{"query": "mutation test { result }", "variables": {"user": "test`)
|
||||||
|
jsonWithNulls = append(jsonWithNulls, 0x00) // Null byte
|
||||||
|
jsonWithNulls = append(jsonWithNulls, []byte(`"}}`)...)
|
||||||
|
|
||||||
|
err := validateRequestBody(jsonWithNulls, client.Logger)
|
||||||
|
assert.Error(err, "JSON with null bytes should fail validation")
|
||||||
|
assert.Contains(err.Error(), "null bytes")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should_test_exact_production_size_scenario", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test the exact scenario that produces ~1093 bytes
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with a longer query that might reach 1093 bytes
|
||||||
|
longQuery := `mutation storeMessage(
|
||||||
|
$group_description: String!,
|
||||||
|
$user_name: String!,
|
||||||
|
$additional_metadata: String,
|
||||||
|
$client_info: String,
|
||||||
|
$request_timestamp: String,
|
||||||
|
$platform_data: String
|
||||||
|
) {
|
||||||
|
insert_message(
|
||||||
|
group_description: $group_description,
|
||||||
|
user_name: $user_name,
|
||||||
|
additional_metadata: $additional_metadata,
|
||||||
|
client_info: $client_info,
|
||||||
|
request_timestamp: $request_timestamp,
|
||||||
|
platform_data: $platform_data
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
status
|
||||||
|
metadata {
|
||||||
|
source
|
||||||
|
platform
|
||||||
|
version
|
||||||
|
client_info
|
||||||
|
request_id
|
||||||
|
processing_time
|
||||||
|
}
|
||||||
|
user_info {
|
||||||
|
name
|
||||||
|
id
|
||||||
|
permissions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// Add additional variables to increase size
|
||||||
|
extendedVariables := make(map[string]interface{})
|
||||||
|
for k, v := range variables {
|
||||||
|
extendedVariables[k] = v
|
||||||
|
}
|
||||||
|
extendedVariables["additional_metadata"] = `{"client": "web", "version": "1.0.0", "features": ["unicode", "emoji"]}`
|
||||||
|
extendedVariables["client_info"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
|
extendedVariables["request_timestamp"] = "2023-05-23T18:01:37.000Z"
|
||||||
|
extendedVariables["platform_data"] = `{"os": "windows", "browser": "chrome", "screen": "1920x1080"}`
|
||||||
|
|
||||||
|
compiledQuery := client.compileQuery(longQuery, extendedVariables)
|
||||||
|
size := len(compiledQuery.JsonQuery)
|
||||||
|
|
||||||
|
t.Logf("Extended query size: %d bytes", size)
|
||||||
|
|
||||||
|
if size >= 1090 && size <= 1100 {
|
||||||
|
t.Logf("🎯 FOUND SIZE MATCH: %d bytes (target: 1093)", size)
|
||||||
|
|
||||||
|
// Validate this doesn't contain jsonQuery field
|
||||||
|
assert.False(bytes.Contains(compiledQuery.JsonQuery, []byte(`"jsonQuery"`)), "Should not contain jsonQuery field")
|
||||||
|
assert.True(json.Valid(compiledQuery.JsonQuery), "Should be valid JSON")
|
||||||
|
assert.True(utf8.Valid(compiledQuery.JsonQuery), "Should be valid UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with server to ensure no trailing garbage
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := make([]byte, r.ContentLength)
|
||||||
|
n, _ := r.Body.Read(body)
|
||||||
|
actualBody := body[:n]
|
||||||
|
|
||||||
|
// Strict validation
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(actualBody))
|
||||||
|
var request map[string]interface{}
|
||||||
|
err := decoder.Decode(&request)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("JSON decode error: %s", err.Error())
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for trailing data
|
||||||
|
remaining := decoder.Buffered()
|
||||||
|
if remaining != nil {
|
||||||
|
buf := make([]byte, 10)
|
||||||
|
n, _ := remaining.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
t.Errorf("Found trailing data: %s", string(buf[:n]))
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"result":"success"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: compiledQuery.JsonQuery,
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json; charset=utf-8"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = server.URL
|
||||||
|
qe.client = server.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err, "Extended query should succeed without trailing garbage")
|
||||||
|
assert.NotNil(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should_demonstrate_diagnostic_logging_output", func(t *testing.T) {
|
||||||
|
// This test specifically demonstrates the diagnostic logging in action
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Use the exact production case
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
// Create a server that will receive the request with diagnostic logging
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Logf("Server received request with Content-Type: %s", r.Header.Get("Content-Type"))
|
||||||
|
t.Logf("Server received Content-Length: %d", r.ContentLength)
|
||||||
|
|
||||||
|
body := make([]byte, r.ContentLength)
|
||||||
|
n, _ := r.Body.Read(body)
|
||||||
|
actualBody := body[:n]
|
||||||
|
|
||||||
|
t.Logf("Server read %d bytes", len(actualBody))
|
||||||
|
t.Logf("Request body first 100 chars: %s", string(actualBody[:min(100, len(actualBody))]))
|
||||||
|
t.Logf("Request body last 50 chars: %s", string(actualBody[max(0, len(actualBody)-50):]))
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"result":"success"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: compiledQuery.JsonQuery,
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json; charset=utf-8"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = server.URL
|
||||||
|
qe.client = server.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
t.Logf("About to execute query with diagnostic logging enabled...")
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.NotNil(result)
|
||||||
|
t.Logf("Query executed successfully with full diagnostic logging")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
assertions "github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUnicodeDebugging creates specific scenarios that might cause "trailing garbage" errors
|
||||||
|
func TestUnicodeDebugging(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
t.Run("should debug exact scenario from user report", func(t *testing.T) {
|
||||||
|
// Recreate the exact scenario from the user's log
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Exact data from the user's report
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }"
|
||||||
|
|
||||||
|
// Compile the query
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
assert.NotNil(compiledQuery)
|
||||||
|
|
||||||
|
jsonBytes := compiledQuery.JsonQuery
|
||||||
|
t.Logf("JSON size: %d bytes", len(jsonBytes))
|
||||||
|
t.Logf("JSON content: %s", string(jsonBytes))
|
||||||
|
|
||||||
|
// Check for potential issues
|
||||||
|
t.Logf("Is valid UTF-8: %v", utf8.Valid(jsonBytes))
|
||||||
|
t.Logf("Is valid JSON: %v", json.Valid(jsonBytes))
|
||||||
|
|
||||||
|
// Check for any trailing bytes or null terminators
|
||||||
|
if len(jsonBytes) > 0 {
|
||||||
|
t.Logf("Last byte: 0x%02x (%c)", jsonBytes[len(jsonBytes)-1], jsonBytes[len(jsonBytes)-1])
|
||||||
|
if len(jsonBytes) > 1 {
|
||||||
|
t.Logf("Second to last byte: 0x%02x (%c)", jsonBytes[len(jsonBytes)-2], jsonBytes[len(jsonBytes)-2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for any BOM (Byte Order Mark)
|
||||||
|
if len(jsonBytes) >= 3 && jsonBytes[0] == 0xEF && jsonBytes[1] == 0xBB && jsonBytes[2] == 0xBF {
|
||||||
|
t.Logf("WARNING: UTF-8 BOM detected at start of JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse with different JSON parsers to see if there are differences
|
||||||
|
var parsed1 map[string]interface{}
|
||||||
|
err1 := json.Unmarshal(jsonBytes, &parsed1)
|
||||||
|
t.Logf("Standard json.Unmarshal error: %v", err1)
|
||||||
|
|
||||||
|
// Check if there are any non-printable characters at the end
|
||||||
|
for i := len(jsonBytes) - 1; i >= 0 && i >= len(jsonBytes)-10; i-- {
|
||||||
|
b := jsonBytes[i]
|
||||||
|
if b < 32 && b != '\n' && b != '\r' && b != '\t' {
|
||||||
|
t.Logf("Non-printable character at position %d: 0x%02x", i, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate the exact request size mentioned in the user's log (1093 bytes)
|
||||||
|
t.Logf("Expected size from user log: 1093 bytes, actual size: %d bytes", len(jsonBytes))
|
||||||
|
if len(jsonBytes) != 1093 {
|
||||||
|
t.Logf("Size mismatch - this might indicate a different encoding or additional data")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should test potential causes of trailing garbage", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test scenarios that might cause trailing garbage
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
variables map[string]interface{}
|
||||||
|
expectIssue bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "double_encoding_scenario",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"json_string": `{"nested": "json with emoji 😈"}`,
|
||||||
|
},
|
||||||
|
expectIssue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped_unicode_scenario",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"escaped": "\\u1F608", // Escaped Unicode
|
||||||
|
},
|
||||||
|
expectIssue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed_encoding_scenario",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"mixed": "ASCII text with 😈 emoji and 巴结 Chinese",
|
||||||
|
},
|
||||||
|
expectIssue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "large_unicode_scenario",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"large": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user": "2eba巴结",
|
||||||
|
"extra": "Additional data to increase size",
|
||||||
|
},
|
||||||
|
expectIssue: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
query := "mutation test($json_string: String, $escaped: String, $mixed: String, $large: String, $user: String, $extra: String) { test }"
|
||||||
|
compiledQuery := client.compileQuery(query, tc.variables)
|
||||||
|
|
||||||
|
assert.NotNil(compiledQuery)
|
||||||
|
jsonBytes := compiledQuery.JsonQuery
|
||||||
|
|
||||||
|
t.Logf("%s - JSON size: %d bytes", tc.name, len(jsonBytes))
|
||||||
|
t.Logf("%s - Is valid UTF-8: %v", tc.name, utf8.Valid(jsonBytes))
|
||||||
|
t.Logf("%s - Is valid JSON: %v", tc.name, json.Valid(jsonBytes))
|
||||||
|
|
||||||
|
// Try to unmarshal
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
err := json.Unmarshal(jsonBytes, &parsed)
|
||||||
|
if tc.expectIssue {
|
||||||
|
assert.Error(err, "Expected error for %s", tc.name)
|
||||||
|
} else {
|
||||||
|
assert.NoError(err, "Should not error for %s", tc.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should test server response to problematic requests", func(t *testing.T) {
|
||||||
|
// Create a server that's very strict about JSON parsing
|
||||||
|
strictServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := make([]byte, r.ContentLength)
|
||||||
|
n, _ := r.Body.Read(body)
|
||||||
|
actualBody := body[:n]
|
||||||
|
|
||||||
|
t.Logf("Server received %d bytes", len(actualBody))
|
||||||
|
t.Logf("Server received content: %s", string(actualBody))
|
||||||
|
|
||||||
|
// Try to parse with strict JSON decoder
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(actualBody))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
|
||||||
|
var request map[string]interface{}
|
||||||
|
err := decoder.Decode(&request)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Server JSON decode error: %s", err.Error())
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(fmt.Sprintf(`{"error":"trailing garbage after JSON","code":"invalid-json","details":"%s"}`, err.Error())))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's more data after the JSON
|
||||||
|
var extra interface{}
|
||||||
|
err = decoder.Decode(&extra)
|
||||||
|
if err == nil {
|
||||||
|
t.Logf("Server found trailing data after JSON")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"trailing garbage after JSON","code":"invalid-json"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"result":"success"}}`))
|
||||||
|
}))
|
||||||
|
defer strictServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: compiledQuery.JsonQuery,
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json; charset=utf-8"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = strictServer.URL
|
||||||
|
qe.client = strictServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Error from strict server: %s", err.Error())
|
||||||
|
} else {
|
||||||
|
t.Logf("Strict server accepted the request successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(err, "Strict server should accept properly encoded JSON")
|
||||||
|
assert.NotNil(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should analyze JSON encoder behavior", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test the exact data structure
|
||||||
|
query := &Query{
|
||||||
|
Query: "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }",
|
||||||
|
Variables: map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test different encoding methods
|
||||||
|
t.Run("using_json_Marshal", func(t *testing.T) {
|
||||||
|
jsonBytes, err := json.Marshal(query)
|
||||||
|
assert.NoError(err)
|
||||||
|
t.Logf("json.Marshal size: %d bytes", len(jsonBytes))
|
||||||
|
t.Logf("json.Marshal valid: %v", json.Valid(jsonBytes))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("using_convertToJSON", func(t *testing.T) {
|
||||||
|
jsonBytes := client.convertToJSON(query)
|
||||||
|
assert.NotNil(jsonBytes)
|
||||||
|
t.Logf("convertToJSON size: %d bytes", len(jsonBytes))
|
||||||
|
t.Logf("convertToJSON valid: %v", json.Valid(jsonBytes))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("using_json_Encoder", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
encoder := json.NewEncoder(&buf)
|
||||||
|
encoder.SetEscapeHTML(false)
|
||||||
|
err := encoder.Encode(query)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
jsonBytes := buf.Bytes()
|
||||||
|
// Remove trailing newline that Encoder adds
|
||||||
|
if len(jsonBytes) > 0 && jsonBytes[len(jsonBytes)-1] == '\n' {
|
||||||
|
jsonBytes = jsonBytes[:len(jsonBytes)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("json.Encoder size: %d bytes", len(jsonBytes))
|
||||||
|
t.Logf("json.Encoder valid: %v", json.Valid(jsonBytes))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnicodeEncodingComparison compares different encoding approaches
|
||||||
|
func TestUnicodeEncodingComparison(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
// Test data that caused the original issue
|
||||||
|
testData := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("compare_encoding_methods", func(t *testing.T) {
|
||||||
|
// Method 1: Standard json.Marshal
|
||||||
|
bytes1, err1 := json.Marshal(testData)
|
||||||
|
assert.NoError(err1)
|
||||||
|
|
||||||
|
// Method 2: json.Encoder with SetEscapeHTML(false)
|
||||||
|
var buf2 bytes.Buffer
|
||||||
|
enc2 := json.NewEncoder(&buf2)
|
||||||
|
enc2.SetEscapeHTML(false)
|
||||||
|
err2 := enc2.Encode(testData)
|
||||||
|
assert.NoError(err2)
|
||||||
|
bytes2 := buf2.Bytes()
|
||||||
|
if len(bytes2) > 0 && bytes2[len(bytes2)-1] == '\n' {
|
||||||
|
bytes2 = bytes2[:len(bytes2)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: json.Encoder with SetEscapeHTML(true) - default
|
||||||
|
var buf3 bytes.Buffer
|
||||||
|
enc3 := json.NewEncoder(&buf3)
|
||||||
|
enc3.SetEscapeHTML(true)
|
||||||
|
err3 := enc3.Encode(testData)
|
||||||
|
assert.NoError(err3)
|
||||||
|
bytes3 := buf3.Bytes()
|
||||||
|
if len(bytes3) > 0 && bytes3[len(bytes3)-1] == '\n' {
|
||||||
|
bytes3 = bytes3[:len(bytes3)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Method 1 (json.Marshal) size: %d", len(bytes1))
|
||||||
|
t.Logf("Method 2 (Encoder, EscapeHTML=false) size: %d", len(bytes2))
|
||||||
|
t.Logf("Method 3 (Encoder, EscapeHTML=true) size: %d", len(bytes3))
|
||||||
|
|
||||||
|
t.Logf("Method 1 content: %s", string(bytes1))
|
||||||
|
t.Logf("Method 2 content: %s", string(bytes2))
|
||||||
|
t.Logf("Method 3 content: %s", string(bytes3))
|
||||||
|
|
||||||
|
// All should be valid
|
||||||
|
assert.True(json.Valid(bytes1))
|
||||||
|
assert.True(json.Valid(bytes2))
|
||||||
|
assert.True(json.Valid(bytes3))
|
||||||
|
|
||||||
|
// Check for differences
|
||||||
|
if !bytes.Equal(bytes1, bytes2) {
|
||||||
|
t.Logf("Difference between Marshal and Encoder(EscapeHTML=false)")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(bytes2, bytes3) {
|
||||||
|
t.Logf("Difference between EscapeHTML=false and EscapeHTML=true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package gql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
assertions "github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUnicodeEncodingIssue reproduces the "trailing garbage" error with emojis and special characters
|
||||||
|
func TestUnicodeEncodingIssue(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
|
||||||
|
t.Run("should handle emoji and Chinese characters in GraphQL variables", func(t *testing.T) {
|
||||||
|
// Create a test server that simulates the problematic scenario
|
||||||
|
var receivedBody []byte
|
||||||
|
var receivedContentType string
|
||||||
|
var receivedContentLength int64
|
||||||
|
|
||||||
|
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Capture the request details for analysis
|
||||||
|
receivedContentType = r.Header.Get("Content-Type")
|
||||||
|
receivedContentLength = r.ContentLength
|
||||||
|
|
||||||
|
// Read the request body to analyze the encoding
|
||||||
|
body := make([]byte, r.ContentLength)
|
||||||
|
r.Body.Read(body)
|
||||||
|
receivedBody = body
|
||||||
|
|
||||||
|
// Log the received data for debugging
|
||||||
|
t.Logf("Received Content-Type: %s", receivedContentType)
|
||||||
|
t.Logf("Received Content-Length: %d", receivedContentLength)
|
||||||
|
t.Logf("Received Body (first 200 chars): %s", string(body[:min(200, len(body))]))
|
||||||
|
t.Logf("Received Body (hex): %x", body[:min(50, len(body))])
|
||||||
|
|
||||||
|
// Check if the JSON is valid
|
||||||
|
var parsedRequest map[string]interface{}
|
||||||
|
err := json.Unmarshal(body, &parsedRequest)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("JSON parsing error: %s", err.Error())
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"trailing garbage after JSON","code":"invalid-json"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"result":"success"}}`))
|
||||||
|
}))
|
||||||
|
defer testServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
// Test data that reproduces the issue
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"group_description": "This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈",
|
||||||
|
"user_name": "2eba巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation storeMessage($group_description: String!, $user_name: String!) { insert_message(group_description: $group_description, user_name: $user_name) { id } }"
|
||||||
|
|
||||||
|
// Compile the query to see how it's encoded
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
assert.NotNil(compiledQuery)
|
||||||
|
assert.NotNil(compiledQuery.JsonQuery)
|
||||||
|
|
||||||
|
t.Logf("Compiled Query JSON: %s", string(compiledQuery.JsonQuery))
|
||||||
|
t.Logf("Compiled Query JSON (hex): %x", compiledQuery.JsonQuery)
|
||||||
|
|
||||||
|
// Check if the JSON is valid UTF-8
|
||||||
|
assert.True(json.Valid(compiledQuery.JsonQuery), "Compiled JSON should be valid")
|
||||||
|
|
||||||
|
// Verify the JSON contains the Unicode characters correctly
|
||||||
|
var parsedQuery map[string]interface{}
|
||||||
|
err := json.Unmarshal(compiledQuery.JsonQuery, &parsedQuery)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
variables_parsed := parsedQuery["variables"].(map[string]interface{})
|
||||||
|
assert.Equal("This a group for serious buyers and sellers\nOf uber eats and Door dash account \nRippers are not allowed in this group😈", variables_parsed["group_description"])
|
||||||
|
assert.Equal("2eba巴结", variables_parsed["user_name"])
|
||||||
|
|
||||||
|
// Execute the query
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: compiledQuery.JsonQuery,
|
||||||
|
Headers: map[string]interface{}{"Content-Type": "application/json; charset=utf-8"},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = testServer.URL
|
||||||
|
qe.client = testServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
|
||||||
|
// Analyze the request that was sent
|
||||||
|
t.Logf("Request body size: %d bytes", len(receivedBody))
|
||||||
|
t.Logf("Content-Type sent: %s", receivedContentType)
|
||||||
|
|
||||||
|
// Check if the request was properly encoded
|
||||||
|
if len(receivedBody) > 0 {
|
||||||
|
var receivedRequest map[string]interface{}
|
||||||
|
parseErr := json.Unmarshal(receivedBody, &receivedRequest)
|
||||||
|
if parseErr != nil {
|
||||||
|
t.Logf("Server couldn't parse JSON: %s", parseErr.Error())
|
||||||
|
t.Logf("Raw body: %s", string(receivedBody))
|
||||||
|
} else {
|
||||||
|
t.Logf("Server successfully parsed JSON")
|
||||||
|
if vars, ok := receivedRequest["variables"].(map[string]interface{}); ok {
|
||||||
|
t.Logf("Received group_description: %s", vars["group_description"])
|
||||||
|
t.Logf("Received user_name: %s", vars["user_name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The test should pass - no trailing garbage errors
|
||||||
|
assert.NoError(err, "Should not get trailing garbage errors with Unicode characters")
|
||||||
|
assert.NotNil(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should properly encode various Unicode characters", func(t *testing.T) {
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
variables map[string]interface{}
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "emoji_only",
|
||||||
|
description: "Test with various emojis",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"message": "Hello 😀😃😄😁😆😅😂🤣😊😇🙂🙃😉😌😍🥰😘😗😙😚😋😛😝😜🤪🤨🧐🤓😎🤩🥳😏😒😞😔😟😕🙁☹️😣😖😫😩🥺😢😭😤😠😡🤬🤯😳🥵🥶😱😨😰😥😓🤗🤔🤭🤫🤥😶😐😑😬🙄😯😦😧😮😲🥱😴🤤😪😵🤐🥴🤢🤮🤧😷🤒🤕🤑🤠😈👿👹👺🤡💩👻💀☠️👽👾🤖🎃😺😸😹😻😼😽🙀😿😾",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chinese_characters",
|
||||||
|
description: "Test with Chinese characters",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"name": "张三李四王五赵六",
|
||||||
|
"address": "北京市朝阳区建国门外大街1号",
|
||||||
|
"company": "中国国际贸易中心有限公司",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "japanese_characters",
|
||||||
|
description: "Test with Japanese characters",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"name": "田中太郎",
|
||||||
|
"address": "東京都渋谷区渋谷1-1-1",
|
||||||
|
"message": "こんにちは、世界!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "korean_characters",
|
||||||
|
description: "Test with Korean characters",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"name": "김철수",
|
||||||
|
"address": "서울특별시 강남구 테헤란로 123",
|
||||||
|
"message": "안녕하세요, 세계!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "arabic_characters",
|
||||||
|
description: "Test with Arabic characters",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"name": "محمد أحمد",
|
||||||
|
"address": "الرياض، المملكة العربية السعودية",
|
||||||
|
"message": "مرحبا بالعالم!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed_unicode",
|
||||||
|
description: "Test with mixed Unicode characters",
|
||||||
|
variables: map[string]interface{}{
|
||||||
|
"message": "Hello 世界 🌍 مرحبا こんにちは 안녕하세요 Здравствуй Bonjour ¡Hola! Γεια σας",
|
||||||
|
"symbols": "©®™€£¥₹₽¢₩₪₫₱₡₦₨₹₽₴₸₼₾₿",
|
||||||
|
"math": "∑∏∫∂∇∆∞≠≤≥±×÷√∛∜∝∞",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
query := "mutation testUnicode($message: String, $name: String, $address: String, $company: String, $symbols: String, $math: String) { test }"
|
||||||
|
|
||||||
|
// Compile the query
|
||||||
|
compiledQuery := client.compileQuery(query, tc.variables)
|
||||||
|
assert.NotNil(compiledQuery, "Query should compile successfully for %s", tc.description)
|
||||||
|
assert.NotNil(compiledQuery.JsonQuery, "JSON query should be generated for %s", tc.description)
|
||||||
|
|
||||||
|
// Verify the JSON is valid
|
||||||
|
assert.True(json.Valid(compiledQuery.JsonQuery), "JSON should be valid for %s", tc.description)
|
||||||
|
|
||||||
|
// Verify we can parse it back
|
||||||
|
var parsedQuery map[string]interface{}
|
||||||
|
err := json.Unmarshal(compiledQuery.JsonQuery, &parsedQuery)
|
||||||
|
assert.NoError(err, "Should be able to parse JSON for %s", tc.description)
|
||||||
|
|
||||||
|
// Verify the variables are preserved correctly
|
||||||
|
if parsedVars, ok := parsedQuery["variables"].(map[string]interface{}); ok {
|
||||||
|
for key, expectedValue := range tc.variables {
|
||||||
|
actualValue := parsedVars[key]
|
||||||
|
assert.Equal(expectedValue, actualValue, "Variable %s should be preserved correctly for %s", key, tc.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ %s: JSON size: %d bytes", tc.description, len(compiledQuery.JsonQuery))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should handle Content-Type with charset specification", func(t *testing.T) {
|
||||||
|
var receivedContentType string
|
||||||
|
var receivedBody []byte
|
||||||
|
|
||||||
|
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedContentType = r.Header.Get("Content-Type")
|
||||||
|
body := make([]byte, r.ContentLength)
|
||||||
|
r.Body.Read(body)
|
||||||
|
receivedBody = body
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"result":"success"}}`))
|
||||||
|
}))
|
||||||
|
defer testServer.Close()
|
||||||
|
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"emoji_text": "Test with emoji 😈 and Chinese 巴结",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation test($emoji_text: String!) { test(text: $emoji_text) }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
// Test different Content-Type headers
|
||||||
|
contentTypes := []string{
|
||||||
|
"application/json",
|
||||||
|
"application/json; charset=utf-8",
|
||||||
|
"application/json; charset=UTF-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, contentType := range contentTypes {
|
||||||
|
t.Run("content_type_"+strings.ReplaceAll(contentType, " ", "_"), func(t *testing.T) {
|
||||||
|
qe := &QueryExecutor{
|
||||||
|
BaseClient: client,
|
||||||
|
Query: compiledQuery.JsonQuery,
|
||||||
|
Headers: map[string]interface{}{"Content-Type": contentType},
|
||||||
|
CacheKey: "no-cache",
|
||||||
|
Retries: false,
|
||||||
|
}
|
||||||
|
qe.endpoint = testServer.URL
|
||||||
|
qe.client = testServer.Client()
|
||||||
|
qe.cache = client.cache
|
||||||
|
|
||||||
|
result, err := qe.executeQuery()
|
||||||
|
|
||||||
|
assert.NoError(err, "Should handle Content-Type: %s", contentType)
|
||||||
|
assert.NotNil(result)
|
||||||
|
assert.Equal(contentType, receivedContentType, "Content-Type should be preserved")
|
||||||
|
|
||||||
|
// Verify the body is valid JSON with Unicode characters
|
||||||
|
var parsedBody map[string]interface{}
|
||||||
|
parseErr := json.Unmarshal(receivedBody, &parsedBody)
|
||||||
|
assert.NoError(parseErr, "Request body should be valid JSON")
|
||||||
|
|
||||||
|
if vars, ok := parsedBody["variables"].(map[string]interface{}); ok {
|
||||||
|
assert.Equal("Test with emoji 😈 and Chinese 巴结", vars["emoji_text"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnicodeEncodingEdgeCases tests edge cases that might cause encoding issues
|
||||||
|
func TestUnicodeEncodingEdgeCases(t *testing.T) {
|
||||||
|
assert := assertions.New(t)
|
||||||
|
client := CreateTestClient()
|
||||||
|
|
||||||
|
t.Run("should handle null bytes and control characters", func(t *testing.T) {
|
||||||
|
// Test with potentially problematic characters
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"text_with_nulls": "Text\x00with\x00null\x00bytes",
|
||||||
|
"text_with_controls": "Text\x01\x02\x03\x04\x05with\x06\x07\x08controls",
|
||||||
|
"text_with_tabs": "Text\twith\ttabs\tand\nnewlines\r\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation test($text_with_nulls: String, $text_with_controls: String, $text_with_tabs: String) { test }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
assert.NotNil(compiledQuery)
|
||||||
|
assert.NotNil(compiledQuery.JsonQuery)
|
||||||
|
|
||||||
|
// The JSON should be valid (Go's json package handles escaping)
|
||||||
|
assert.True(json.Valid(compiledQuery.JsonQuery))
|
||||||
|
|
||||||
|
// Verify we can parse it back
|
||||||
|
var parsedQuery map[string]interface{}
|
||||||
|
err := json.Unmarshal(compiledQuery.JsonQuery, &parsedQuery)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
t.Logf("JSON with control characters: %s", string(compiledQuery.JsonQuery))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should handle very long Unicode strings", func(t *testing.T) {
|
||||||
|
// Create a very long string with Unicode characters
|
||||||
|
longUnicodeString := strings.Repeat("Hello 世界 😀 مرحبا ", 1000)
|
||||||
|
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"long_unicode": longUnicodeString,
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation test($long_unicode: String!) { test }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
assert.NotNil(compiledQuery)
|
||||||
|
assert.NotNil(compiledQuery.JsonQuery)
|
||||||
|
assert.True(json.Valid(compiledQuery.JsonQuery))
|
||||||
|
|
||||||
|
t.Logf("Long Unicode JSON size: %d bytes", len(compiledQuery.JsonQuery))
|
||||||
|
|
||||||
|
// Verify the content is preserved
|
||||||
|
var parsedQuery map[string]interface{}
|
||||||
|
err := json.Unmarshal(compiledQuery.JsonQuery, &parsedQuery)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
if vars, ok := parsedQuery["variables"].(map[string]interface{}); ok {
|
||||||
|
assert.Equal(longUnicodeString, vars["long_unicode"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should handle Unicode normalization edge cases", func(t *testing.T) {
|
||||||
|
// Test different Unicode normalization forms
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"composed": "é", // U+00E9 (composed)
|
||||||
|
"decomposed": "e\u0301", // U+0065 U+0301 (decomposed)
|
||||||
|
"ligature": "fi", // U+FB01 (ligature)
|
||||||
|
"surrogate": "𝕳𝖊𝖑𝖑𝖔", // Mathematical script letters (surrogate pairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "mutation test($composed: String, $decomposed: String, $ligature: String, $surrogate: String) { test }"
|
||||||
|
compiledQuery := client.compileQuery(query, variables)
|
||||||
|
|
||||||
|
assert.NotNil(compiledQuery)
|
||||||
|
assert.NotNil(compiledQuery.JsonQuery)
|
||||||
|
assert.True(json.Valid(compiledQuery.JsonQuery))
|
||||||
|
|
||||||
|
// Verify all forms are preserved
|
||||||
|
var parsedQuery map[string]interface{}
|
||||||
|
err := json.Unmarshal(compiledQuery.JsonQuery, &parsedQuery)
|
||||||
|
assert.NoError(err)
|
||||||
|
|
||||||
|
if vars, ok := parsedQuery["variables"].(map[string]interface{}); ok {
|
||||||
|
for key, expectedValue := range variables {
|
||||||
|
assert.Equal(expectedValue, vars[key], "Unicode form should be preserved for %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Unicode normalization test JSON: %s", string(compiledQuery.JsonQuery))
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user