A lot more tests

This commit is contained in:
Hein 2025-12-08 16:56:48 +02:00
parent aeae9d7e0c
commit c9eaf84125
28 changed files with 4036 additions and 444 deletions

View File

@ -1,4 +1,4 @@
name: Tests name: Build , Vet Test, and Lint
on: on:
push: push:
@ -9,7 +9,7 @@ on:
jobs: jobs:
test: test:
name: Run Tests name: Run Vet Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
@ -38,22 +38,6 @@ jobs:
- name: Run go vet - name: Run go vet
run: go vet ./... run: go vet ./...
- name: Run tests
run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
- name: Display test coverage
run: go tool cover -func=coverage.out
# - name: Upload coverage to Codecov
# uses: codecov/codecov-action@v4
# with:
# file: ./coverage.out
# flags: unittests
# name: codecov-umbrella
# env:
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# continue-on-error: true
lint: lint:
name: Lint Code name: Lint Code
runs-on: ubuntu-latest runs-on: ubuntu-latest

91
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,91 @@
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.21"
- name: Run unit tests
run: go test ./pkg/resolvespec ./pkg/restheadspec -v -cover
- name: Generate coverage report
run: |
go test ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
- name: Upload coverage
uses: actions/upload-artifact@v3
with:
name: coverage-report
path: coverage.html
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.21"
- name: Create test databases
env:
PGPASSWORD: postgres
run: |
psql -h localhost -U postgres -c "CREATE DATABASE resolvespec_test;"
psql -h localhost -U postgres -c "CREATE DATABASE restheadspec_test;"
- name: Run resolvespec integration tests
env:
TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
run: go test -tags=integration ./pkg/resolvespec -v
- name: Run restheadspec integration tests
env:
TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=restheadspec_test port=5432 sslmode=disable"
run: go test -tags=integration ./pkg/restheadspec -v
- name: Generate integration coverage
env:
TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
run: |
go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage-integration.out
go tool cover -html=coverage-integration.out -o coverage-integration.html
- name: Upload integration coverage
uses: actions/upload-artifact@v3
with:
name: integration-coverage-report
path: coverage-integration.html

56
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,56 @@
{
"go.testFlags": [
"-v"
],
"go.testTimeout": "300s",
"go.coverOnSave": false,
"go.coverOnSingleTest": true,
"go.coverageDecorator": {
"type": "gutter"
},
"go.testEnvVars": {
"TEST_DATABASE_URL": "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
},
"go.toolsEnvVars": {
"CGO_ENABLED": "0"
},
"go.buildTags": "",
"go.testTags": "",
"files.exclude": {
"**/.git": true,
"**/.DS_Store": true,
"**/coverage.out": true,
"**/coverage.html": true,
"**/coverage-integration.out": true,
"**/coverage-integration.html": true
},
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/*/**": true,
"**/.hg/store/**": true,
"**/vendor/**": true
},
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"[go]": {
"editor.defaultFormatter": "golang.go",
"editor.formatOnSave": true,
"editor.insertSpaces": false,
"editor.tabSize": 4
},
"gopls": {
"ui.completion.usePlaceholders": true,
"ui.semanticTokens": true,
"ui.codelenses": {
"generate": true,
"regenerate_cgo": true,
"test": true,
"tidy": true,
"upgrade_dependency": true,
"vendor": true
}
}
}

217
.vscode/tasks.json vendored
View File

@ -9,7 +9,7 @@
"env": { "env": {
"CGO_ENABLED": "0" "CGO_ENABLED": "0"
}, },
"cwd": "${workspaceFolder}/bin", "cwd": "${workspaceFolder}/bin"
}, },
"args": [ "args": [
"../..." "../..."
@ -17,11 +17,179 @@
"problemMatcher": [ "problemMatcher": [
"$go" "$go"
], ],
"group": "build", "group": "build"
},
{
"type": "shell",
"label": "test: unit tests (all)",
"command": "go test ./pkg/resolvespec ./pkg/restheadspec -v -cover",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "shared",
"focus": true
}
},
{
"type": "shell",
"label": "test: unit tests (resolvespec)",
"command": "go test ./pkg/resolvespec -v -cover",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"type": "shell",
"label": "test: unit tests (restheadspec)",
"command": "go test ./pkg/restheadspec -v -cover",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"type": "shell",
"label": "test: integration tests (automated)",
"command": "./scripts/run-integration-tests.sh",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "dedicated",
"focus": true
}
},
{
"type": "shell",
"label": "test: integration tests (resolvespec only)",
"command": "./scripts/run-integration-tests.sh resolvespec",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "dedicated"
}
},
{
"type": "shell",
"label": "test: integration tests (restheadspec only)",
"command": "./scripts/run-integration-tests.sh restheadspec",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "dedicated"
}
},
{
"type": "shell",
"label": "test: coverage report",
"command": "make coverage",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"type": "shell",
"label": "test: integration coverage report",
"command": "make coverage-integration",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"type": "shell",
"label": "docker: start postgres",
"command": "make docker-up",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"type": "shell",
"label": "docker: stop postgres",
"command": "make docker-down",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"type": "shell",
"label": "docker: clean postgres data",
"command": "make clean",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "shared"
}
}, },
{ {
"type": "go", "type": "go",
"label": "go: test workspace", "label": "go: test workspace (with race)",
"command": "test", "command": "test",
"options": { "options": {
"cwd": "${workspaceFolder}" "cwd": "${workspaceFolder}"
@ -36,13 +204,10 @@
"problemMatcher": [ "problemMatcher": [
"$go" "$go"
], ],
"group": { "group": "test",
"kind": "test",
"isDefault": true
},
"presentation": { "presentation": {
"reveal": "always", "reveal": "always",
"panel": "new" "panel": "shared"
} }
}, },
{ {
@ -69,23 +234,45 @@
}, },
{ {
"type": "shell", "type": "shell",
"label": "go: full test suite", "label": "test: all tests (unit + integration)",
"command": "make test",
"options": {
"cwd": "${workspaceFolder}"
},
"dependsOn": [
"docker: start postgres"
],
"problemMatcher": [
"$go"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "dedicated",
"focus": true
}
},
{
"type": "shell",
"label": "test: full suite with checks",
"dependsOrder": "sequence", "dependsOrder": "sequence",
"dependsOn": [ "dependsOn": [
"go: vet workspace", "go: vet workspace",
"go: test workspace" "test: unit tests (all)",
"test: integration tests (automated)"
], ],
"problemMatcher": [], "problemMatcher": [],
"group": { "group": "test",
"kind": "test", "presentation": {
"isDefault": false "reveal": "always",
"panel": "dedicated"
} }
}, },
{ {
"type": "shell", "type": "shell",
"label": "Make Release", "label": "Make Release",
"problemMatcher": [], "problemMatcher": [],
"command": "sh ${workspaceFolder}/make_release.sh", "command": "sh ${workspaceFolder}/make_release.sh"
} }
] ]
} }

View File

@ -1,173 +0,0 @@
# Migration Guide: Database and Router Abstraction
This guide explains how to migrate from the direct GORM/Router dependencies to the new abstracted interfaces.
## Overview of Changes
### What was changed:
1. **Database Operations**: GORM-specific code is now abstracted behind `Database` interface
2. **Router Integration**: HTTP router dependencies are abstracted behind `Router` interface
3. **Model Registry**: Models are now managed through a `ModelRegistry` interface
4. **Backward Compatibility**: Existing code continues to work with `NewAPIHandler()`
### Benefits:
- **Database Flexibility**: Switch between GORM, Bun, or other ORMs without code changes
- **Router Flexibility**: Use Gorilla Mux, Gin, Echo, or other routers
- **Better Testing**: Easy to mock database and router interactions
- **Cleaner Separation**: Business logic separated from ORM/router specifics
## Migration Path
### Option 1: No Changes Required (Backward Compatible)
Your existing code continues to work without any changes:
```go
// This still works exactly as before
handler := resolvespec.NewAPIHandler(db)
```
### Option 2: Gradual Migration to New API
#### Step 1: Use New Handler Constructor
```go
// Old way
handler := resolvespec.NewAPIHandler(gormDB)
// New way
handler := resolvespec.NewHandlerWithGORM(gormDB)
```
#### Step 2: Use Interface-based Approach
```go
// Create database adapter
dbAdapter := resolvespec.NewGormAdapter(gormDB)
// Create model registry
registry := resolvespec.NewModelRegistry()
// Register your models
registry.RegisterModel("public.users", &User{})
registry.RegisterModel("public.orders", &Order{})
// Create handler
handler := resolvespec.NewHandler(dbAdapter, registry)
```
## Switching Database Backends
### From GORM to Bun
```go
// Add bun dependency first:
// go get github.com/uptrace/bun
// Old GORM setup
gormDB, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
gormAdapter := resolvespec.NewGormAdapter(gormDB)
// New Bun setup
sqlDB, _ := sql.Open("sqlite3", "test.db")
bunDB := bun.NewDB(sqlDB, sqlitedialect.New())
bunAdapter := resolvespec.NewBunAdapter(bunDB)
// Handler creation is identical
handler := resolvespec.NewHandler(bunAdapter, registry)
```
## Router Flexibility
### Current Gorilla Mux (Default)
```go
router := mux.NewRouter()
resolvespec.SetupRoutes(router, handler)
```
### BunRouter (Built-in Support)
```go
// Simple setup
router := bunrouter.New()
resolvespec.SetupBunRouterWithResolveSpec(router, handler)
// Or using adapter
routerAdapter := resolvespec.NewStandardBunRouterAdapter()
// Use routerAdapter.GetBunRouter() for the underlying router
```
### Using Router Adapters (Advanced)
```go
// For when you want router abstraction
routerAdapter := resolvespec.NewStandardRouter()
routerAdapter.RegisterRoute("/{schema}/{entity}", handlerFunc)
```
## Model Registration
### Old Way (Still Works)
```go
// Models registered through existing models package
handler.RegisterModel("public", "users", &User{})
```
### New Way (Recommended)
```go
registry := resolvespec.NewModelRegistry()
registry.RegisterModel("public.users", &User{})
registry.RegisterModel("public.orders", &Order{})
handler := resolvespec.NewHandler(dbAdapter, registry)
```
## Interface Definitions
### Database Interface
```go
type Database interface {
NewSelect() SelectQuery
NewInsert() InsertQuery
NewUpdate() UpdateQuery
NewDelete() DeleteQuery
// ... transaction methods
}
```
### Available Adapters
- `GormAdapter` - For GORM (ready to use)
- `BunAdapter` - For Bun (add dependency: `go get github.com/uptrace/bun`)
- Easy to create custom adapters for other ORMs
## Testing Benefits
### Before (Tightly Coupled)
```go
// Hard to test - requires real GORM setup
func TestHandler(t *testing.T) {
db := setupRealGormDB()
handler := resolvespec.NewAPIHandler(db)
// ... test logic
}
```
### After (Mockable)
```go
// Easy to test - mock the interfaces
func TestHandler(t *testing.T) {
mockDB := &MockDatabase{}
mockRegistry := &MockModelRegistry{}
handler := resolvespec.NewHandler(mockDB, mockRegistry)
// ... test logic with mocks
}
```
## Breaking Changes
- **None for existing code** - Full backward compatibility maintained
- New interfaces are additive, not replacing existing APIs
## Recommended Migration Timeline
1. **Phase 1**: Use existing code (no changes needed)
2. **Phase 2**: Gradually adopt new constructors (`NewHandlerWithGORM`)
3. **Phase 3**: Move to interface-based approach when needed
4. **Phase 4**: Switch database backends if desired
## Getting Help
- Check example functions in `resolvespec.go`
- Review interface definitions in `database.go`
- Examine adapter implementations for patterns

66
Makefile Normal file
View File

@ -0,0 +1,66 @@
.PHONY: test test-unit test-integration docker-up docker-down clean
# Run all unit tests
test-unit:
@echo "Running unit tests..."
@go test ./pkg/resolvespec ./pkg/restheadspec -v -cover
# Run all integration tests (requires PostgreSQL)
test-integration:
@echo "Running integration tests..."
@go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v
# Run all tests (unit + integration)
test: test-unit test-integration
# Start PostgreSQL for integration tests
docker-up:
@echo "Starting PostgreSQL container..."
@docker-compose up -d postgres-test
@echo "Waiting for PostgreSQL to be ready..."
@sleep 5
@echo "PostgreSQL is ready!"
# Stop PostgreSQL container
docker-down:
@echo "Stopping PostgreSQL container..."
@docker-compose down
# Clean up Docker volumes and test data
clean:
@echo "Cleaning up..."
@docker-compose down -v
@echo "Cleanup complete!"
# Run integration tests with Docker (full workflow)
test-integration-docker: docker-up
@echo "Running integration tests with Docker..."
@go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v
@$(MAKE) docker-down
# Check test coverage
coverage:
@echo "Generating coverage report..."
@go test ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage.out
@go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"
# Run integration tests coverage
coverage-integration:
@echo "Generating integration test coverage report..."
@go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage-integration.out
@go tool cover -html=coverage-integration.out -o coverage-integration.html
@echo "Integration coverage report generated: coverage-integration.html"
help:
@echo "Available targets:"
@echo " test-unit - Run unit tests"
@echo " test-integration - Run integration tests (requires PostgreSQL)"
@echo " test - Run all tests"
@echo " docker-up - Start PostgreSQL container"
@echo " docker-down - Stop PostgreSQL container"
@echo " test-integration-docker - Run integration tests with Docker (automated)"
@echo " clean - Clean up Docker volumes"
@echo " coverage - Generate unit test coverage report"
@echo " coverage-integration - Generate integration test coverage report"
@echo " help - Show this help message"

514
README.md
View File

@ -1,81 +1,83 @@
# 📜 ResolveSpec 📜 # 📜 ResolveSpec 📜
![Tests](https://github.com/bitechdev/ResolveSpec/workflows/Tests/badge.svg) ![1.00](https://github.com/bitechdev/ResolveSpec/workflows/Tests/badge.svg)
ResolveSpec is a flexible and powerful REST API specification and implementation that provides GraphQL-like capabilities while maintaining REST simplicity. It offers **two complementary approaches**: ResolveSpec is a flexible and powerful REST API specification and implementation that provides GraphQL-like capabilities while maintaining REST simplicity. It offers **two complementary approaches**:
1. **ResolveSpec** - Body-based API with JSON request options 1. **ResolveSpec** - Body-based API with JSON request options
2. **RestHeadSpec** - Header-based API where query options are passed via HTTP headers 2. **RestHeadSpec** - Header-based API where query options are passed via HTTP headers
3. **FuncSpec** - Header-based API to map and call API's to sql functions.
Both share the same core architecture and provide dynamic data querying, relationship preloading, and complex filtering. Both share the same core architecture and provide dynamic data querying, relationship preloading, and complex filtering.
**🆕 New in v2.0**: Database-agnostic architecture with support for GORM, Bun, and other ORMs. Router-flexible design works with Gorilla Mux, Gin, Echo, and more. Documentation Generated by LLMs
**🆕 New in v2.1**: RestHeadSpec (HeaderSpec) - Header-based REST API with lifecycle hooks, cursor pagination, and advanced filtering. ![1.00](./generated_slogan.webp)
**🆕 New in v3.0**: Explicit route registration - Routes are now created per registered model for better flexibility and control. OPTIONS method support with full CORS headers for cross-origin requests.
![slogan](./generated_slogan.webp)
## Table of Contents ## Table of Contents
- [Features](#features) * [Features](#features)
- [Installation](#installation) * [Installation](#installation)
- [Quick Start](#quick-start) * [Quick Start](#quick-start)
- [ResolveSpec (Body-Based API)](#resolvespec-body-based-api) * [ResolveSpec (Body-Based API)](#resolvespec-body-based-api)
- [RestHeadSpec (Header-Based API)](#restheadspec-header-based-api) * [RestHeadSpec (Header-Based API)](#restheadspec-header-based-api)
- [Existing Code (Backward Compatible)](#option-1-existing-code-backward-compatible) * [Existing Code (Backward Compatible)](#option-1-existing-code-backward-compatible)
- [New Database-Agnostic API](#option-2-new-database-agnostic-api) * [New Database-Agnostic API](#option-2-new-database-agnostic-api)
- [Router Integration](#router-integration) * [Router Integration](#router-integration)
- [Migration from v1.x](#migration-from-v1x) * [Migration from v1.x](#migration-from-v1x)
- [Architecture](#architecture) * [Architecture](#architecture)
- [API Structure](#api-structure) * [API Structure](#api-structure)
- [RestHeadSpec: Header-Based API](#restheadspec-header-based-api-1) * [RestHeadSpec: Header-Based API](#restheadspec-header-based-api-1)
- [Lifecycle Hooks](#lifecycle-hooks) * [Lifecycle Hooks](#lifecycle-hooks)
- [Cursor Pagination](#cursor-pagination) * [Cursor Pagination](#cursor-pagination)
- [Response Formats](#response-formats) * [Response Formats](#response-formats)
- [Single Record as Object](#single-record-as-object-default-behavior) * [Single Record as Object](#single-record-as-object-default-behavior)
- [Example Usage](#example-usage) * [Example Usage](#example-usage)
- [Recursive CRUD Operations](#recursive-crud-operations-) * [Recursive CRUD Operations](#recursive-crud-operations-)
- [Testing](#testing) * [Testing](#testing)
- [What's New](#whats-new) * [What's New](#whats-new)
## Features ## Features
### Core Features ### Core Features
- **Dynamic Data Querying**: Select specific columns and relationships to return
- **Relationship Preloading**: Load related entities with custom column selection and filters * **Dynamic Data Querying**: Select specific columns and relationships to return
- **Complex Filtering**: Apply multiple filters with various operators * **Relationship Preloading**: Load related entities with custom column selection and filters
- **Sorting**: Multi-column sort support * **Complex Filtering**: Apply multiple filters with various operators
- **Pagination**: Built-in limit/offset and cursor-based pagination * **Sorting**: Multi-column sort support
- **Computed Columns**: Define virtual columns for complex calculations * **Pagination**: Built-in limit/offset and cursor-based pagination
- **Custom Operators**: Add custom SQL conditions when needed * **Computed Columns**: Define virtual columns for complex calculations
- **🆕 Recursive CRUD Handler**: Automatically handle nested object graphs with foreign key resolution and per-record operation control via `_request` field * **Custom Operators**: Add custom SQL conditions when needed
* **🆕 Recursive CRUD Handler**: Automatically handle nested object graphs with foreign key resolution and per-record operation control via `_request` field
### Architecture (v2.0+) ### Architecture (v2.0+)
- **🆕 Database Agnostic**: Works with GORM, Bun, or any database layer through adapters
- **🆕 Router Flexible**: Integrates with Gorilla Mux, Gin, Echo, or custom routers * **🆕 Database Agnostic**: Works with GORM, Bun, or any database layer through adapters
- **🆕 Backward Compatible**: Existing code works without changes * **🆕 Router Flexible**: Integrates with Gorilla Mux, Gin, Echo, or custom routers
- **🆕 Better Testing**: Mockable interfaces for easy unit testing * **🆕 Backward Compatible**: Existing code works without changes
* **🆕 Better Testing**: Mockable interfaces for easy unit testing
### RestHeadSpec (v2.1+) ### RestHeadSpec (v2.1+)
- **🆕 Header-Based API**: All query options passed via HTTP headers instead of request body
- **🆕 Lifecycle Hooks**: Before/after hooks for create, read, update, and delete operations * **🆕 Header-Based API**: All query options passed via HTTP headers instead of request body
- **🆕 Cursor Pagination**: Efficient cursor-based pagination with complex sort support * **🆕 Lifecycle Hooks**: Before/after hooks for create, read, update, and delete operations
- **🆕 Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible formats * **🆕 Cursor Pagination**: Efficient cursor-based pagination with complex sort support
- **🆕 Single Record as Object**: Automatically normalize single-element arrays to objects (enabled by default) * **🆕 Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible formats
- **🆕 Advanced Filtering**: Field filters, search operators, AND/OR logic, and custom SQL * **🆕 Single Record as Object**: Automatically normalize single-element arrays to objects (enabled by default)
- **🆕 Base64 Encoding**: Support for base64-encoded header values * **🆕 Advanced Filtering**: Field filters, search operators, AND/OR logic, and custom SQL
* **🆕 Base64 Encoding**: Support for base64-encoded header values
### Routing & CORS (v3.0+) ### Routing & CORS (v3.0+)
- **🆕 Explicit Route Registration**: Routes created per registered model instead of dynamic lookups
- **🆕 OPTIONS Method Support**: Full OPTIONS method support returning model metadata * **🆕 Explicit Route Registration**: Routes created per registered model instead of dynamic lookups
- **🆕 CORS Headers**: Comprehensive CORS support with all HeadSpec headers allowed * **🆕 OPTIONS Method Support**: Full OPTIONS method support returning model metadata
- **🆕 Better Route Control**: Customize routes per model with more flexibility * **🆕 CORS Headers**: Comprehensive CORS support with all HeadSpec headers allowed
* **🆕 Better Route Control**: Customize routes per model with more flexibility
## API Structure ## API Structure
### URL Patterns ### URL Patterns
``` ```
/[schema]/[table_or_entity]/[id] /[schema]/[table_or_entity]/[id]
/[schema]/[table_or_entity] /[schema]/[table_or_entity]
@ -85,7 +87,7 @@ Both share the same core architecture and provide dynamic data querying, relatio
### Request Format ### Request Format
```json ```JSON
{ {
"operation": "read|create|update|delete", "operation": "read|create|update|delete",
"data": { "data": {
@ -110,7 +112,7 @@ RestHeadSpec provides an alternative REST API approach where all query options a
### Quick Example ### Quick Example
```http ```HTTP
GET /public/users HTTP/1.1 GET /public/users HTTP/1.1
Host: api.example.com Host: api.example.com
X-Select-Fields: id,name,email,department_id X-Select-Fields: id,name,email,department_id
@ -124,7 +126,7 @@ X-DetailApi: true
### Setup with GORM ### Setup with GORM
```go ```Go
import "github.com/bitechdev/ResolveSpec/pkg/restheadspec" import "github.com/bitechdev/ResolveSpec/pkg/restheadspec"
import "github.com/gorilla/mux" import "github.com/gorilla/mux"
@ -147,7 +149,7 @@ http.ListenAndServe(":8080", router)
### Setup with Bun ORM ### Setup with Bun ORM
```go ```Go
import "github.com/bitechdev/ResolveSpec/pkg/restheadspec" import "github.com/bitechdev/ResolveSpec/pkg/restheadspec"
import "github.com/uptrace/bun" import "github.com/uptrace/bun"
@ -164,19 +166,19 @@ restheadspec.SetupMuxRoutes(router, handler)
### Common Headers ### Common Headers
| Header | Description | Example | | Header | Description | Example |
|--------|-------------|---------| | --------------------------- | -------------------------------------------------- | ------------------------------ |
| `X-Select-Fields` | Columns to include | `id,name,email` | | `X-Select-Fields` | Columns to include | `id,name,email` |
| `X-Not-Select-Fields` | Columns to exclude | `password,internal_notes` | | `X-Not-Select-Fields` | Columns to exclude | `password,internal_notes` |
| `X-FieldFilter-{col}` | Exact match filter | `X-FieldFilter-Status: active` | | `X-FieldFilter-{col}` | Exact match filter | `X-FieldFilter-Status: active` |
| `X-SearchFilter-{col}` | Fuzzy search (ILIKE) | `X-SearchFilter-Name: john` | | `X-SearchFilter-{col}` | Fuzzy search (ILIKE) | `X-SearchFilter-Name: john` |
| `X-SearchOp-{op}-{col}` | Filter with operator | `X-SearchOp-Gte-Age: 18` | | `X-SearchOp-{op}-{col}` | Filter with operator | `X-SearchOp-Gte-Age: 18` |
| `X-Preload` | Preload relations | `posts:id,title` | | `X-Preload` | Preload relations | `posts:id,title` |
| `X-Sort` | Sort columns | `-created_at,+name` | | `X-Sort` | Sort columns | `-created_at,+name` |
| `X-Limit` | Limit results | `50` | | `X-Limit` | Limit results | `50` |
| `X-Offset` | Offset for pagination | `100` | | `X-Offset` | Offset for pagination | `100` |
| `X-Clean-JSON` | Remove null/empty fields | `true` | | `X-Clean-JSON` | Remove null/empty fields | `true` |
| `X-Single-Record-As-Object` | Return single records as objects (default: `true`) | `false` | | `X-Single-Record-As-Object` | Return single records as objects (default: `true`) | `false` |
**Available Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `startswith`, `endswith`, `between`, `betweeninclusive`, `in`, `empty`, `notempty` **Available Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `startswith`, `endswith`, `between`, `betweeninclusive`, `in`, `empty`, `notempty`
@ -187,11 +189,14 @@ For complete header documentation, see [pkg/restheadspec/HEADERS.md](pkg/resthea
ResolveSpec and RestHeadSpec include comprehensive CORS support for cross-origin requests: ResolveSpec and RestHeadSpec include comprehensive CORS support for cross-origin requests:
**OPTIONS Method**: **OPTIONS Method**:
```http
```HTTP
OPTIONS /public/users HTTP/1.1 OPTIONS /public/users HTTP/1.1
``` ```
Returns metadata with appropriate CORS headers: Returns metadata with appropriate CORS headers:
```http
```HTTP
Access-Control-Allow-Origin: * Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Select-Fields, X-FieldFilter-*, ... Access-Control-Allow-Headers: Content-Type, Authorization, X-Select-Fields, X-FieldFilter-*, ...
@ -200,14 +205,16 @@ Access-Control-Allow-Credentials: true
``` ```
**Key Features**: **Key Features**:
- OPTIONS returns model metadata (same as GET metadata endpoint)
- All HTTP methods include CORS headers automatically * OPTIONS returns model metadata (same as GET metadata endpoint)
- OPTIONS requests don't require authentication (CORS preflight) * All HTTP methods include CORS headers automatically
- Supports all HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.) * OPTIONS requests don't require authentication (CORS preflight)
- 24-hour max age to reduce preflight requests * Supports all HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.)
* 24-hour max age to reduce preflight requests
**Configuration**: **Configuration**:
```go
```Go
import "github.com/bitechdev/ResolveSpec/pkg/common" import "github.com/bitechdev/ResolveSpec/pkg/common"
// Get default CORS config // Get default CORS config
@ -222,7 +229,7 @@ corsConfig.AllowedMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
RestHeadSpec supports lifecycle hooks for all CRUD operations: RestHeadSpec supports lifecycle hooks for all CRUD operations:
```go ```Go
import "github.com/bitechdev/ResolveSpec/pkg/restheadspec" import "github.com/bitechdev/ResolveSpec/pkg/restheadspec"
// Create handler // Create handler
@ -267,27 +274,29 @@ handler.Hooks.Register(restheadspec.BeforeCreate, func(ctx *restheadspec.HookCon
``` ```
**Available Hook Types**: **Available Hook Types**:
- `BeforeRead`, `AfterRead`
- `BeforeCreate`, `AfterCreate` * `BeforeRead`, `AfterRead`
- `BeforeUpdate`, `AfterUpdate` * `BeforeCreate`, `AfterCreate`
- `BeforeDelete`, `AfterDelete` * `BeforeUpdate`, `AfterUpdate`
* `BeforeDelete`, `AfterDelete`
**HookContext** provides: **HookContext** provides:
- `Context`: Request context
- `Handler`: Access to handler, database, and registry * `Context`: Request context
- `Schema`, `Entity`, `TableName`: Request info * `Handler`: Access to handler, database, and registry
- `Model`: The registered model type * `Schema`, `Entity`, `TableName`: Request info
- `Options`: Parsed request options (filters, sorting, etc.) * `Model`: The registered model type
- `ID`: Record ID (for single-record operations) * `Options`: Parsed request options (filters, sorting, etc.)
- `Data`: Request data (for create/update) * `ID`: Record ID (for single-record operations)
- `Result`: Operation result (for after hooks) * `Data`: Request data (for create/update)
- `Writer`: Response writer (allows hooks to modify response) * `Result`: Operation result (for after hooks)
* `Writer`: Response writer (allows hooks to modify response)
### Cursor Pagination ### Cursor Pagination
RestHeadSpec supports efficient cursor-based pagination for large datasets: RestHeadSpec supports efficient cursor-based pagination for large datasets:
```http ```HTTP
GET /public/posts HTTP/1.1 GET /public/posts HTTP/1.1
X-Sort: -created_at,+id X-Sort: -created_at,+id
X-Limit: 50 X-Limit: 50
@ -295,20 +304,22 @@ X-Cursor-Forward: <cursor_token>
``` ```
**How it works**: **How it works**:
1. First request returns results + cursor token in response 1. First request returns results + cursor token in response
2. Subsequent requests use `X-Cursor-Forward` or `X-Cursor-Backward` 2. Subsequent requests use `X-Cursor-Forward` or `X-Cursor-Backward`
3. Cursor maintains consistent ordering even with data changes 3. Cursor maintains consistent ordering even with data changes
4. Supports complex multi-column sorting 4. Supports complex multi-column sorting
**Benefits over offset pagination**: **Benefits over offset pagination**:
- Consistent results when data changes
- Better performance for large offsets * Consistent results when data changes
- Prevents "skipped" or duplicate records * Better performance for large offsets
- Works with complex sort expressions * Prevents "skipped" or duplicate records
* Works with complex sort expressions
**Example with hooks**: **Example with hooks**:
```go ```Go
// Enable cursor pagination in a hook // Enable cursor pagination in a hook
handler.Hooks.Register(restheadspec.BeforeRead, func(ctx *restheadspec.HookContext) error { handler.Hooks.Register(restheadspec.BeforeRead, func(ctx *restheadspec.HookContext) error {
// For large tables, enforce cursor pagination // For large tables, enforce cursor pagination
@ -324,7 +335,8 @@ handler.Hooks.Register(restheadspec.BeforeRead, func(ctx *restheadspec.HookConte
RestHeadSpec supports multiple response formats: RestHeadSpec supports multiple response formats:
**1. Simple Format** (`X-SimpleApi: true`): **1. Simple Format** (`X-SimpleApi: true`):
```json
```JSON
[ [
{ "id": 1, "name": "John" }, { "id": 1, "name": "John" },
{ "id": 2, "name": "Jane" } { "id": 2, "name": "Jane" }
@ -332,7 +344,8 @@ RestHeadSpec supports multiple response formats:
``` ```
**2. Detail Format** (`X-DetailApi: true`, default): **2. Detail Format** (`X-DetailApi: true`, default):
```json
```JSON
{ {
"success": true, "success": true,
"data": [...], "data": [...],
@ -346,7 +359,8 @@ RestHeadSpec supports multiple response formats:
``` ```
**3. Syncfusion Format** (`X-Syncfusion: true`): **3. Syncfusion Format** (`X-Syncfusion: true`):
```json
```JSON
{ {
"result": [...], "result": [...],
"count": 100 "count": 100
@ -358,10 +372,12 @@ RestHeadSpec supports multiple response formats:
By default, RestHeadSpec automatically converts single-element arrays into objects for cleaner API responses. This provides a better developer experience when fetching individual records. By default, RestHeadSpec automatically converts single-element arrays into objects for cleaner API responses. This provides a better developer experience when fetching individual records.
**Default behavior (enabled)**: **Default behavior (enabled)**:
```http
```HTTP
GET /public/users/123 GET /public/users/123
``` ```
```json
```JSON
{ {
"success": true, "success": true,
"data": { "id": 123, "name": "John", "email": "john@example.com" } "data": { "id": 123, "name": "John", "email": "john@example.com" }
@ -369,7 +385,8 @@ GET /public/users/123
``` ```
Instead of: Instead of:
```json
```JSON
{ {
"success": true, "success": true,
"data": [{ "id": 123, "name": "John", "email": "john@example.com" }] "data": [{ "id": 123, "name": "John", "email": "john@example.com" }]
@ -377,11 +394,13 @@ Instead of:
``` ```
**To disable** (force arrays for consistency): **To disable** (force arrays for consistency):
```http
```HTTP
GET /public/users/123 GET /public/users/123
X-Single-Record-As-Object: false X-Single-Record-As-Object: false
``` ```
```json
```JSON
{ {
"success": true, "success": true,
"data": [{ "id": 123, "name": "John", "email": "john@example.com" }] "data": [{ "id": 123, "name": "John", "email": "john@example.com" }]
@ -389,23 +408,26 @@ X-Single-Record-As-Object: false
``` ```
**How it works**: **How it works**:
- When a query returns exactly **one record**, it's returned as an object
- When a query returns **multiple records**, they're returned as an array * When a query returns exactly **one record**, it's returned as an object
- Set `X-Single-Record-As-Object: false` to always receive arrays * When a query returns **multiple records**, they're returned as an array
- Works with all response formats (simple, detail, syncfusion) * Set `X-Single-Record-As-Object: false` to always receive arrays
- Applies to both read operations and create/update returning clauses * Works with all response formats (simple, detail, syncfusion)
* Applies to both read operations and create/update returning clauses
**Benefits**: **Benefits**:
- Cleaner API responses for single-record queries
- No need to unwrap single-element arrays on the client side * Cleaner API responses for single-record queries
- Better TypeScript/type inference support * No need to unwrap single-element arrays on the client side
- Consistent with common REST API patterns * Better TypeScript/type inference support
- Backward compatible via header opt-out * Consistent with common REST API patterns
* Backward compatible via header opt-out
## Example Usage ## Example Usage
### Reading Data with Related Entities ### Reading Data with Related Entities
```json
```JSON
POST /core/users POST /core/users
{ {
"operation": "read", "operation": "read",
@ -449,7 +471,7 @@ ResolveSpec now supports automatic handling of nested object graphs with intelli
#### Creating Nested Objects #### Creating Nested Objects
```json ```JSON
POST /core/users POST /core/users
{ {
"operation": "create", "operation": "create",
@ -482,7 +504,7 @@ POST /core/users
Control individual operations for each nested record using the special `_request` field: Control individual operations for each nested record using the special `_request` field:
```json ```JSON
POST /core/users/123 POST /core/users/123
{ {
"operation": "update", "operation": "update",
@ -508,11 +530,12 @@ POST /core/users/123
} }
``` ```
**Supported `_request` values**: **Supported** **`_request`** **values**:
- `insert` - Create a new related record
- `update` - Update an existing related record * `insert` - Create a new related record
- `delete` - Delete a related record * `update` - Update an existing related record
- `upsert` - Create if doesn't exist, update if exists * `delete` - Delete a related record
* `upsert` - Create if doesn't exist, update if exists
#### How It Works #### How It Works
@ -524,14 +547,14 @@ POST /core/users/123
#### Benefits #### Benefits
- Reduce API round trips for complex object graphs * Reduce API round trips for complex object graphs
- Maintain referential integrity automatically * Maintain referential integrity automatically
- Simplify client-side code * Simplify client-side code
- Atomic operations with automatic rollback on errors * Atomic operations with automatic rollback on errors
## Installation ## Installation
```bash ```Shell
go get github.com/bitechdev/ResolveSpec go get github.com/bitechdev/ResolveSpec
``` ```
@ -541,7 +564,7 @@ go get github.com/bitechdev/ResolveSpec
ResolveSpec uses JSON request bodies to specify query options: ResolveSpec uses JSON request bodies to specify query options:
```go ```Go
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec" import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
// Create handler // Create handler
@ -568,7 +591,7 @@ resolvespec.SetupRoutes(router, handler)
RestHeadSpec uses HTTP headers for query options instead of request body: RestHeadSpec uses HTTP headers for query options instead of request body:
```go ```Go
import "github.com/bitechdev/ResolveSpec/pkg/restheadspec" import "github.com/bitechdev/ResolveSpec/pkg/restheadspec"
// Create handler with GORM // Create handler with GORM
@ -597,7 +620,7 @@ See [RestHeadSpec: Header-Based API](#restheadspec-header-based-api-1) for compl
Your existing code continues to work without any changes: Your existing code continues to work without any changes:
```go ```Go
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec" import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
// This still works exactly as before // This still works exactly as before
@ -615,7 +638,7 @@ ResolveSpec v2.0 introduces a new database and router abstraction layer while ma
To update your imports: To update your imports:
```bash ```Shell
# Update go.mod # Update go.mod
go mod edit -replace github.com/Warky-Devs/ResolveSpec=github.com/bitechdev/ResolveSpec@latest go mod edit -replace github.com/Warky-Devs/ResolveSpec=github.com/bitechdev/ResolveSpec@latest
go mod tidy go mod tidy
@ -627,7 +650,7 @@ go mod tidy
Alternatively, use find and replace in your project: Alternatively, use find and replace in your project:
```bash ```Shell
find . -type f -name "*.go" -exec sed -i 's|github.com/Warky-Devs/ResolveSpec|github.com/bitechdev/ResolveSpec|g' {} + find . -type f -name "*.go" -exec sed -i 's|github.com/Warky-Devs/ResolveSpec|github.com/bitechdev/ResolveSpec|g' {} +
go mod tidy go mod tidy
``` ```
@ -642,7 +665,7 @@ go mod tidy
### Detailed Migration Guide ### Detailed Migration Guide
For detailed migration instructions, examples, and best practices, see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md). For detailed migration instructions, examples, and best practices, see [MIGRATION\_GUIDE.md](MIGRATION_GUIDE.md).
## Architecture ## Architecture
@ -684,22 +707,23 @@ Your Application Code
### Supported Database Layers ### Supported Database Layers
- **GORM** (default, fully supported) * **GORM** (default, fully supported)
- **Bun** (ready to use, included in dependencies) * **Bun** (ready to use, included in dependencies)
- **Custom ORMs** (implement the `Database` interface) * **Custom ORMs** (implement the `Database` interface)
### Supported Routers ### Supported Routers
- **Gorilla Mux** (built-in support with `SetupRoutes()`) * **Gorilla Mux** (built-in support with `SetupRoutes()`)
- **BunRouter** (built-in support with `SetupBunRouterWithResolveSpec()`) * **BunRouter** (built-in support with `SetupBunRouterWithResolveSpec()`)
- **Gin** (manual integration, see examples above) * **Gin** (manual integration, see examples above)
- **Echo** (manual integration, see examples above) * **Echo** (manual integration, see examples above)
- **Custom Routers** (implement request/response adapters) * **Custom Routers** (implement request/response adapters)
### Option 2: New Database-Agnostic API ### Option 2: New Database-Agnostic API
#### With GORM (Recommended Migration Path) #### With GORM (Recommended Migration Path)
```go
```Go
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec" import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
// Create database adapter // Create database adapter
@ -715,7 +739,8 @@ handler := resolvespec.NewHandler(dbAdapter, registry)
``` ```
#### With Bun ORM #### With Bun ORM
```go
```Go
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec" import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
import "github.com/uptrace/bun" import "github.com/uptrace/bun"
@ -730,7 +755,8 @@ handler := resolvespec.NewHandler(dbAdapter, registry)
### Router Integration ### Router Integration
#### Gorilla Mux (Built-in Support) #### Gorilla Mux (Built-in Support)
```go
```Go
import "github.com/gorilla/mux" import "github.com/gorilla/mux"
// Register models first // Register models first
@ -746,7 +772,8 @@ resolvespec.SetupMuxRoutes(router, handler, nil)
``` ```
#### Gin (Custom Integration) #### Gin (Custom Integration)
```go
```Go
import "github.com/gin-gonic/gin" import "github.com/gin-gonic/gin"
func setupGin(handler *resolvespec.Handler) *gin.Engine { func setupGin(handler *resolvespec.Handler) *gin.Engine {
@ -769,7 +796,8 @@ func setupGin(handler *resolvespec.Handler) *gin.Engine {
``` ```
#### Echo (Custom Integration) #### Echo (Custom Integration)
```go
```Go
import "github.com/labstack/echo/v4" import "github.com/labstack/echo/v4"
func setupEcho(handler *resolvespec.Handler) *echo.Echo { func setupEcho(handler *resolvespec.Handler) *echo.Echo {
@ -792,7 +820,8 @@ func setupEcho(handler *resolvespec.Handler) *echo.Echo {
``` ```
#### BunRouter (Built-in Support) #### BunRouter (Built-in Support)
```go
```Go
import "github.com/uptrace/bunrouter" import "github.com/uptrace/bunrouter"
// Simple setup with built-in function // Simple setup with built-in function
@ -837,7 +866,8 @@ func setupFullUptrace(bunDB *bun.DB) *bunrouter.Router {
## Configuration ## Configuration
### Model Registration ### Model Registration
```go
```Go
type User struct { type User struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name"` Name string `json:"name"`
@ -851,20 +881,24 @@ handler.RegisterModel("core", "users", &User{})
## Features in Detail ## Features in Detail
### Filtering ### Filtering
Supported operators: Supported operators:
- eq: Equal
- neq: Not Equal * eq: Equal
- gt: Greater Than * neq: Not Equal
- gte: Greater Than or Equal * gt: Greater Than
- lt: Less Than * gte: Greater Than or Equal
- lte: Less Than or Equal * lt: Less Than
- like: LIKE pattern matching * lte: Less Than or Equal
- ilike: Case-insensitive LIKE * like: LIKE pattern matching
- in: IN clause * ilike: Case-insensitive LIKE
* in: IN clause
### Sorting ### Sorting
Support for multiple sort criteria with direction: Support for multiple sort criteria with direction:
```json
```JSON
"sort": [ "sort": [
{ {
"column": "created_at", "column": "created_at",
@ -878,8 +912,10 @@ Support for multiple sort criteria with direction:
``` ```
### Computed Columns ### Computed Columns
Define virtual columns using SQL expressions: Define virtual columns using SQL expressions:
```json
```JSON
"computedColumns": [ "computedColumns": [
{ {
"name": "full_name", "name": "full_name",
@ -892,7 +928,7 @@ Define virtual columns using SQL expressions:
### With New Architecture (Mockable) ### With New Architecture (Mockable)
```go ```Go
import "github.com/stretchr/testify/mock" import "github.com/stretchr/testify/mock"
// Create mock database // Create mock database
@ -927,14 +963,14 @@ ResolveSpec uses GitHub Actions for automated testing and quality checks. The CI
The project includes automated workflows that: The project includes automated workflows that:
- **Test**: Run all tests with race detection and code coverage * **Test**: Run all tests with race detection and code coverage
- **Lint**: Check code quality with golangci-lint * **Lint**: Check code quality with golangci-lint
- **Build**: Verify the project builds successfully * **Build**: Verify the project builds successfully
- **Multi-version**: Test against multiple Go versions (1.23.x, 1.24.x) * **Multi-version**: Test against multiple Go versions (1.23.x, 1.24.x)
### Running Tests Locally ### Running Tests Locally
```bash ```Shell
# Run all tests # Run all tests
go test -v ./... go test -v ./...
@ -952,13 +988,13 @@ golangci-lint run
The project includes comprehensive test coverage: The project includes comprehensive test coverage:
- **Unit Tests**: Individual component testing * **Unit Tests**: Individual component testing
- **Integration Tests**: End-to-end API testing * **Integration Tests**: End-to-end API testing
- **CRUD Tests**: Standalone tests for both ResolveSpec and RestHeadSpec APIs * **CRUD Tests**: Standalone tests for both ResolveSpec and RestHeadSpec APIs
To run only the CRUD standalone tests: To run only the CRUD standalone tests:
```bash ```Shell
go test -v ./tests -run TestCRUDStandalone go test -v ./tests -run TestCRUDStandalone
``` ```
@ -970,18 +1006,18 @@ Check the [Actions tab](../../actions) on GitHub to see the status of recent CI
Add this badge to display CI status in your fork: Add this badge to display CI status in your fork:
```markdown ```Markdown
![Tests](https://github.com/bitechdev/ResolveSpec/workflows/Tests/badge.svg) ![Tests](https://github.com/bitechdev/ResolveSpec/workflows/Tests/badge.svg)
``` ```
## Security Considerations ## Security Considerations
- Implement proper authentication and authorization * Implement proper authentication and authorization
- Validate all input parameters * Validate all input parameters
- Use prepared statements (handled by GORM/Bun/your ORM) * Use prepared statements (handled by GORM/Bun/your ORM)
- Implement rate limiting * Implement rate limiting
- Control access at schema/entity level * Control access at schema/entity level
- **New**: Database abstraction layer provides additional security through interface boundaries * **New**: Database abstraction layer provides additional security through interface boundaries
## Contributing ## Contributing
@ -993,94 +1029,106 @@ Add this badge to display CI status in your fork:
## License ## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## What's New ## What's New
### v3.0 (Latest - December 2025) ### v3.0 (Latest - December 2025)
**Explicit Route Registration (🆕)**: **Explicit Route Registration (🆕)**:
- **Breaking Change**: Routes are now created explicitly for each registered model
- **Better Control**: Customize routes per model with more flexibility * **Breaking Change**: Routes are now created explicitly for each registered model
- **Registration Order**: Models must be registered BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes * **Better Control**: Customize routes per model with more flexibility
- **Benefits**: More flexible routing, easier to add custom routes per model, better performance * **Registration Order**: Models must be registered BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes
* **Benefits**: More flexible routing, easier to add custom routes per model, better performance
**OPTIONS Method & CORS Support (🆕)**: **OPTIONS Method & CORS Support (🆕)**:
- **OPTIONS Endpoint**: Full OPTIONS method support for CORS preflight requests
- **Metadata Response**: OPTIONS returns model metadata (same as GET /metadata) * **OPTIONS Endpoint**: Full OPTIONS method support for CORS preflight requests
- **CORS Headers**: Comprehensive CORS headers on all responses * **Metadata Response**: OPTIONS returns model metadata (same as GET /metadata)
- **Header Support**: All HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.) allowed * **CORS Headers**: Comprehensive CORS headers on all responses
- **No Auth on OPTIONS**: CORS preflight requests don't require authentication * **Header Support**: All HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.) allowed
- **Configurable**: Customize CORS settings via `common.CORSConfig` * **No Auth on OPTIONS**: CORS preflight requests don't require authentication
* **Configurable**: Customize CORS settings via `common.CORSConfig`
**Migration Notes**: **Migration Notes**:
- Update your code to register models BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes
- Routes like `/public/users` are now created per registered model instead of using dynamic `/{schema}/{entity}` pattern * Update your code to register models BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes
- This is a **breaking change** but provides better control and flexibility * Routes like `/public/users` are now created per registered model instead of using dynamic `/{schema}/{entity}` pattern
* This is a **breaking change** but provides better control and flexibility
### v2.1 ### v2.1
**Recursive CRUD Handler (🆕 Nov 11, 2025)**: **Recursive CRUD Handler (🆕 Nov 11, 2025)**:
- **Nested Object Graphs**: Automatically handle complex object hierarchies with parent-child relationships
- **Foreign Key Resolution**: Automatic propagation of parent IDs to child records * **Nested Object Graphs**: Automatically handle complex object hierarchies with parent-child relationships
- **Per-Record Operations**: Control create/update/delete operations per record via `_request` field * **Foreign Key Resolution**: Automatic propagation of parent IDs to child records
- **Transaction Safety**: All nested operations execute atomically within database transactions * **Per-Record Operations**: Control create/update/delete operations per record via `_request` field
- **Relationship Detection**: Automatic detection of belongsTo, hasMany, hasOne, and many2many relationships * **Transaction Safety**: All nested operations execute atomically within database transactions
- **Deep Nesting Support**: Handle relationships at any depth level * **Relationship Detection**: Automatic detection of belongsTo, hasMany, hasOne, and many2many relationships
- **Mixed Operations**: Combine insert, update, and delete operations in a single request * **Deep Nesting Support**: Handle relationships at any depth level
* **Mixed Operations**: Combine insert, update, and delete operations in a single request
**Primary Key Improvements (Nov 11, 2025)**: **Primary Key Improvements (Nov 11, 2025)**:
- **GetPrimaryKeyName**: Enhanced primary key detection for better preload and ID field handling
- **Better GORM/Bun Support**: Improved compatibility with both ORMs for primary key operations * **GetPrimaryKeyName**: Enhanced primary key detection for better preload and ID field handling
- **Computed Column Support**: Fixed computed columns functionality across handlers * **Better GORM/Bun Support**: Improved compatibility with both ORMs for primary key operations
* **Computed Column Support**: Fixed computed columns functionality across handlers
**Database Adapter Enhancements (Nov 11, 2025)**: **Database Adapter Enhancements (Nov 11, 2025)**:
- **Bun ORM Relations**: Using Scan model method for better has-many and many-to-many relationship handling
- **Model Method Support**: Enhanced query building with proper model registration * **Bun ORM Relations**: Using Scan model method for better has-many and many-to-many relationship handling
- **Improved Type Safety**: Better handling of relationship queries with type-aware scanning * **Model Method Support**: Enhanced query building with proper model registration
* **Improved Type Safety**: Better handling of relationship queries with type-aware scanning
**RestHeadSpec - Header-Based REST API**: **RestHeadSpec - Header-Based REST API**:
- **Header-Based Querying**: All query options via HTTP headers instead of request body
- **Lifecycle Hooks**: Before/after hooks for create, read, update, delete operations * **Header-Based Querying**: All query options via HTTP headers instead of request body
- **Cursor Pagination**: Efficient cursor-based pagination with complex sorting * **Lifecycle Hooks**: Before/after hooks for create, read, update, delete operations
- **Advanced Filtering**: Field filters, search operators, AND/OR logic * **Cursor Pagination**: Efficient cursor-based pagination with complex sorting
- **Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible responses * **Advanced Filtering**: Field filters, search operators, AND/OR logic
- **Single Record as Object**: Automatically return single-element arrays as objects (default, toggleable via header) * **Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible responses
- **Base64 Support**: Base64-encoded header values for complex queries * **Single Record as Object**: Automatically return single-element arrays as objects (default, toggleable via header)
- **Type-Aware Filtering**: Automatic type detection and conversion for filters * **Base64 Support**: Base64-encoded header values for complex queries
* **Type-Aware Filtering**: Automatic type detection and conversion for filters
**Core Improvements**: **Core Improvements**:
- Better model registry with schema.table format support
- Enhanced validation and error handling * Better model registry with schema.table format support
- Improved reflection safety * Enhanced validation and error handling
- Fixed COUNT query issues with table aliasing * Improved reflection safety
- Better pointer handling throughout the codebase * Fixed COUNT query issues with table aliasing
- **Comprehensive Test Coverage**: Added standalone CRUD tests for both ResolveSpec and RestHeadSpec * Better pointer handling throughout the codebase
* **Comprehensive Test Coverage**: Added standalone CRUD tests for both ResolveSpec and RestHeadSpec
### v2.0 ### v2.0
**Breaking Changes**: **Breaking Changes**:
- **None!** Full backward compatibility maintained
* **None!** Full backward compatibility maintained
**New Features**: **New Features**:
- **Database Abstraction**: Support for GORM, Bun, and custom ORMs
- **Router Flexibility**: Works with any HTTP router through adapters * **Database Abstraction**: Support for GORM, Bun, and custom ORMs
- **BunRouter Integration**: Built-in support for uptrace/bunrouter * **Router Flexibility**: Works with any HTTP router through adapters
- **Better Architecture**: Clean separation of concerns with interfaces * **BunRouter Integration**: Built-in support for uptrace/bunrouter
- **Enhanced Testing**: Mockable interfaces for comprehensive testing * **Better Architecture**: Clean separation of concerns with interfaces
- **Migration Guide**: Step-by-step migration instructions * **Enhanced Testing**: Mockable interfaces for comprehensive testing
* **Migration Guide**: Step-by-step migration instructions
**Performance Improvements**: **Performance Improvements**:
- More efficient query building through interface design
- Reduced coupling between components * More efficient query building through interface design
- Better memory management with interface boundaries * Reduced coupling between components
* Better memory management with interface boundaries
## Acknowledgments ## Acknowledgments
- Inspired by REST, OData, and GraphQL's flexibility * Inspired by REST, OData, and GraphQL's flexibility
- **Header-based approach**: Inspired by REST best practices and clean API design * **Header-based approach**: Inspired by REST best practices and clean API design
- **Database Support**: [GORM](https://gorm.io) and [Bun](https://bun.uptrace.dev/) * **Database Support**: [GORM](https://gorm.io) and [Bun](https://bun.uptrace.dev/)
- **Router Support**: Gorilla Mux (built-in), BunRouter, Gin, Echo, and others through adapters * **Router Support**: Gorilla Mux (built-in), BunRouter, Gin, Echo, and others through adapters
- Slogan generated using DALL-E * Slogan generated using DALL-E
- AI used for documentation checking and correction * AI used for documentation checking and correction
- Community feedback and contributions that made v2.0 and v2.1 possible * Community feedback and contributions that made v2.0 and v2.1 possible

27
docker-compose.yml Normal file
View File

@ -0,0 +1,27 @@
services:
postgres-test:
image: postgres:15-alpine
container_name: resolvespec-postgres-test
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- "5434:5432"
volumes:
- postgres-test-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
- resolvespec-test
volumes:
postgres-test-data:
driver: local
networks:
resolvespec-test:
driver: bridge

7
go.mod
View File

@ -25,6 +25,7 @@ require (
go.opentelemetry.io/otel/trace v1.38.0 go.opentelemetry.io/otel/trace v1.38.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.25.12 gorm.io/gorm v1.25.12
) )
@ -39,6 +40,10 @@ require (
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@ -61,8 +66,10 @@ require (
go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect
golang.org/x/net v0.43.0 // indirect golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.28.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect

17
go.sum
View File

@ -10,6 +10,7 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
@ -37,6 +38,14 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@ -75,6 +84,9 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@ -126,6 +138,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
@ -156,8 +170,11 @@ google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn
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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=

View File

@ -0,0 +1,138 @@
package resolvespec
import (
"context"
"testing"
)
func TestContextOperations(t *testing.T) {
ctx := context.Background()
// Test Schema
t.Run("WithSchema and GetSchema", func(t *testing.T) {
ctx = WithSchema(ctx, "public")
schema := GetSchema(ctx)
if schema != "public" {
t.Errorf("Expected schema 'public', got '%s'", schema)
}
})
// Test Entity
t.Run("WithEntity and GetEntity", func(t *testing.T) {
ctx = WithEntity(ctx, "users")
entity := GetEntity(ctx)
if entity != "users" {
t.Errorf("Expected entity 'users', got '%s'", entity)
}
})
// Test TableName
t.Run("WithTableName and GetTableName", func(t *testing.T) {
ctx = WithTableName(ctx, "public.users")
tableName := GetTableName(ctx)
if tableName != "public.users" {
t.Errorf("Expected tableName 'public.users', got '%s'", tableName)
}
})
// Test Model
t.Run("WithModel and GetModel", func(t *testing.T) {
type TestModel struct {
ID int
Name string
}
model := &TestModel{ID: 1, Name: "test"}
ctx = WithModel(ctx, model)
retrieved := GetModel(ctx)
if retrieved == nil {
t.Error("Expected model to be retrieved, got nil")
}
if retrievedModel, ok := retrieved.(*TestModel); ok {
if retrievedModel.ID != 1 || retrievedModel.Name != "test" {
t.Errorf("Expected model with ID=1 and Name='test', got ID=%d, Name='%s'", retrievedModel.ID, retrievedModel.Name)
}
} else {
t.Error("Retrieved model is not of expected type")
}
})
// Test ModelPtr
t.Run("WithModelPtr and GetModelPtr", func(t *testing.T) {
type TestModel struct {
ID int
}
models := []*TestModel{}
ctx = WithModelPtr(ctx, &models)
retrieved := GetModelPtr(ctx)
if retrieved == nil {
t.Error("Expected modelPtr to be retrieved, got nil")
}
})
// Test WithRequestData
t.Run("WithRequestData", func(t *testing.T) {
type TestModel struct {
ID int
Name string
}
model := &TestModel{ID: 1, Name: "test"}
modelPtr := &[]*TestModel{}
ctx = WithRequestData(ctx, "test_schema", "test_entity", "test_schema.test_entity", model, modelPtr)
if GetSchema(ctx) != "test_schema" {
t.Errorf("Expected schema 'test_schema', got '%s'", GetSchema(ctx))
}
if GetEntity(ctx) != "test_entity" {
t.Errorf("Expected entity 'test_entity', got '%s'", GetEntity(ctx))
}
if GetTableName(ctx) != "test_schema.test_entity" {
t.Errorf("Expected tableName 'test_schema.test_entity', got '%s'", GetTableName(ctx))
}
if GetModel(ctx) == nil {
t.Error("Expected model to be set")
}
if GetModelPtr(ctx) == nil {
t.Error("Expected modelPtr to be set")
}
})
}
func TestEmptyContext(t *testing.T) {
ctx := context.Background()
t.Run("GetSchema with empty context", func(t *testing.T) {
schema := GetSchema(ctx)
if schema != "" {
t.Errorf("Expected empty schema, got '%s'", schema)
}
})
t.Run("GetEntity with empty context", func(t *testing.T) {
entity := GetEntity(ctx)
if entity != "" {
t.Errorf("Expected empty entity, got '%s'", entity)
}
})
t.Run("GetTableName with empty context", func(t *testing.T) {
tableName := GetTableName(ctx)
if tableName != "" {
t.Errorf("Expected empty tableName, got '%s'", tableName)
}
})
t.Run("GetModel with empty context", func(t *testing.T) {
model := GetModel(ctx)
if model != nil {
t.Errorf("Expected nil model, got %v", model)
}
})
t.Run("GetModelPtr with empty context", func(t *testing.T) {
modelPtr := GetModelPtr(ctx)
if modelPtr != nil {
t.Errorf("Expected nil modelPtr, got %v", modelPtr)
}
})
}

View File

@ -0,0 +1,367 @@
package resolvespec
import (
"reflect"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
func TestNewHandler(t *testing.T) {
// Note: We can't create a real handler without actual DB and registry
// But we can test that the constructor doesn't panic with nil values
handler := NewHandler(nil, nil)
if handler == nil {
t.Error("Expected handler to be created, got nil")
}
if handler.hooks == nil {
t.Error("Expected hooks registry to be initialized")
}
}
func TestHandlerHooks(t *testing.T) {
handler := NewHandler(nil, nil)
hooks := handler.Hooks()
if hooks == nil {
t.Error("Expected hooks registry, got nil")
}
}
func TestSetFallbackHandler(t *testing.T) {
handler := NewHandler(nil, nil)
// We can't directly call the fallback without mocks, but we can verify it's set
handler.SetFallbackHandler(func(w common.ResponseWriter, r common.Request, params map[string]string) {
// Fallback handler implementation
})
if handler.fallbackHandler == nil {
t.Error("Expected fallback handler to be set")
}
}
func TestGetDatabase(t *testing.T) {
handler := NewHandler(nil, nil)
db := handler.GetDatabase()
// Should return nil since we passed nil
if db != nil {
t.Error("Expected nil database")
}
}
func TestParseTableName(t *testing.T) {
handler := NewHandler(nil, nil)
tests := []struct {
name string
fullTableName string
expectedSchema string
expectedTable string
}{
{
name: "Table with schema",
fullTableName: "public.users",
expectedSchema: "public",
expectedTable: "users",
},
{
name: "Table without schema",
fullTableName: "users",
expectedSchema: "",
expectedTable: "users",
},
{
name: "Multiple dots (use last)",
fullTableName: "db.public.users",
expectedSchema: "db.public",
expectedTable: "users",
},
{
name: "Empty string",
fullTableName: "",
expectedSchema: "",
expectedTable: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schema, table := handler.parseTableName(tt.fullTableName)
if schema != tt.expectedSchema {
t.Errorf("Expected schema '%s', got '%s'", tt.expectedSchema, schema)
}
if table != tt.expectedTable {
t.Errorf("Expected table '%s', got '%s'", tt.expectedTable, table)
}
})
}
}
func TestGetColumnType(t *testing.T) {
tests := []struct {
name string
field reflect.StructField
expectedType string
}{
{
name: "String field",
field: reflect.StructField{
Name: "Name",
Type: reflect.TypeOf(""),
},
expectedType: "string",
},
{
name: "Int field",
field: reflect.StructField{
Name: "Count",
Type: reflect.TypeOf(int(0)),
},
expectedType: "integer",
},
{
name: "Int32 field",
field: reflect.StructField{
Name: "ID",
Type: reflect.TypeOf(int32(0)),
},
expectedType: "integer",
},
{
name: "Int64 field",
field: reflect.StructField{
Name: "BigID",
Type: reflect.TypeOf(int64(0)),
},
expectedType: "bigint",
},
{
name: "Float32 field",
field: reflect.StructField{
Name: "Price",
Type: reflect.TypeOf(float32(0)),
},
expectedType: "float",
},
{
name: "Float64 field",
field: reflect.StructField{
Name: "Amount",
Type: reflect.TypeOf(float64(0)),
},
expectedType: "double",
},
{
name: "Bool field",
field: reflect.StructField{
Name: "Active",
Type: reflect.TypeOf(false),
},
expectedType: "boolean",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
colType := getColumnType(tt.field)
if colType != tt.expectedType {
t.Errorf("Expected column type '%s', got '%s'", tt.expectedType, colType)
}
})
}
}
func TestIsNullable(t *testing.T) {
tests := []struct {
name string
field reflect.StructField
nullable bool
}{
{
name: "Pointer type is nullable",
field: reflect.StructField{
Name: "Name",
Type: reflect.TypeOf((*string)(nil)),
},
nullable: true,
},
{
name: "Non-pointer type without explicit 'not null' tag",
field: reflect.StructField{
Name: "ID",
Type: reflect.TypeOf(int(0)),
},
nullable: true, // isNullable returns true if there's no explicit "not null" tag
},
{
name: "Field with 'not null' tag is not nullable",
field: reflect.StructField{
Name: "Email",
Type: reflect.TypeOf(""),
Tag: `gorm:"not null"`,
},
nullable: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isNullable(tt.field)
if result != tt.nullable {
t.Errorf("Expected nullable=%v, got %v", tt.nullable, result)
}
})
}
}
func TestToSnakeCase(t *testing.T) {
tests := []struct {
input string
expected string
}{
{
input: "UserID",
expected: "user_id",
},
{
input: "DepartmentName",
expected: "department_name",
},
{
input: "ID",
expected: "id",
},
{
input: "HTTPServer",
expected: "http_server",
},
{
input: "createdAt",
expected: "created_at",
},
{
input: "name",
expected: "name",
},
{
input: "",
expected: "",
},
{
input: "A",
expected: "a",
},
{
input: "APIKey",
expected: "api_key",
},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := toSnakeCase(tt.input)
if result != tt.expected {
t.Errorf("toSnakeCase(%q) = %q, expected %q", tt.input, result, tt.expected)
}
})
}
}
func TestExtractTagValue(t *testing.T) {
handler := NewHandler(nil, nil)
tests := []struct {
name string
tag string
key string
expected string
}{
{
name: "Extract foreignKey",
tag: "foreignKey:UserID;references:ID",
key: "foreignKey",
expected: "UserID",
},
{
name: "Extract references",
tag: "foreignKey:UserID;references:ID",
key: "references",
expected: "ID",
},
{
name: "Key not found",
tag: "foreignKey:UserID;references:ID",
key: "notfound",
expected: "",
},
{
name: "Empty tag",
tag: "",
key: "foreignKey",
expected: "",
},
{
name: "Single value",
tag: "many2many:user_roles",
key: "many2many",
expected: "user_roles",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := handler.extractTagValue(tt.tag, tt.key)
if result != tt.expected {
t.Errorf("extractTagValue(%q, %q) = %q, expected %q", tt.tag, tt.key, result, tt.expected)
}
})
}
}
func TestApplyFilter(t *testing.T) {
// Note: Without a real database, we can't fully test query execution
// But we can test that the method exists
_ = NewHandler(nil, nil)
// The applyFilter method exists and can be tested with actual queries
// but requires database setup which is beyond unit test scope
t.Log("applyFilter method exists and is used in handler operations")
}
func TestShouldUseNestedProcessor(t *testing.T) {
handler := NewHandler(nil, nil)
tests := []struct {
name string
data map[string]interface{}
expected bool
}{
{
name: "Has _request field",
data: map[string]interface{}{
"_request": "nested",
"name": "test",
},
expected: true,
},
{
name: "No special fields",
data: map[string]interface{}{
"name": "test",
"email": "test@example.com",
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Note: Without a real model, we can't fully test this
// But we can verify the function exists
result := handler.shouldUseNestedProcessor(tt.data, nil)
// The actual result depends on the model structure
_ = result
})
}
}

View File

@ -0,0 +1,400 @@
package resolvespec
import (
"context"
"fmt"
"testing"
)
func TestHookRegistry(t *testing.T) {
registry := NewHookRegistry()
// Test registering a hook
called := false
hook := func(ctx *HookContext) error {
called = true
return nil
}
registry.Register(BeforeRead, hook)
if registry.Count(BeforeRead) != 1 {
t.Errorf("Expected 1 hook, got %d", registry.Count(BeforeRead))
}
// Test executing a hook
ctx := &HookContext{
Context: context.Background(),
Schema: "test",
Entity: "users",
}
err := registry.Execute(BeforeRead, ctx)
if err != nil {
t.Errorf("Hook execution failed: %v", err)
}
if !called {
t.Error("Hook was not called")
}
}
func TestHookExecutionOrder(t *testing.T) {
registry := NewHookRegistry()
order := []int{}
hook1 := func(ctx *HookContext) error {
order = append(order, 1)
return nil
}
hook2 := func(ctx *HookContext) error {
order = append(order, 2)
return nil
}
hook3 := func(ctx *HookContext) error {
order = append(order, 3)
return nil
}
registry.Register(BeforeCreate, hook1)
registry.Register(BeforeCreate, hook2)
registry.Register(BeforeCreate, hook3)
ctx := &HookContext{
Context: context.Background(),
Schema: "test",
Entity: "users",
}
err := registry.Execute(BeforeCreate, ctx)
if err != nil {
t.Errorf("Hook execution failed: %v", err)
}
if len(order) != 3 {
t.Errorf("Expected 3 hooks to be called, got %d", len(order))
}
if order[0] != 1 || order[1] != 2 || order[2] != 3 {
t.Errorf("Hooks executed in wrong order: %v", order)
}
}
func TestHookError(t *testing.T) {
registry := NewHookRegistry()
executed := []string{}
hook1 := func(ctx *HookContext) error {
executed = append(executed, "hook1")
return nil
}
hook2 := func(ctx *HookContext) error {
executed = append(executed, "hook2")
return fmt.Errorf("hook2 error")
}
hook3 := func(ctx *HookContext) error {
executed = append(executed, "hook3")
return nil
}
registry.Register(BeforeUpdate, hook1)
registry.Register(BeforeUpdate, hook2)
registry.Register(BeforeUpdate, hook3)
ctx := &HookContext{
Context: context.Background(),
Schema: "test",
Entity: "users",
}
err := registry.Execute(BeforeUpdate, ctx)
if err == nil {
t.Error("Expected error from hook execution")
}
if len(executed) != 2 {
t.Errorf("Expected only 2 hooks to be executed, got %d", len(executed))
}
if executed[0] != "hook1" || executed[1] != "hook2" {
t.Errorf("Unexpected execution order: %v", executed)
}
}
func TestHookDataModification(t *testing.T) {
registry := NewHookRegistry()
modifyHook := func(ctx *HookContext) error {
if dataMap, ok := ctx.Data.(map[string]interface{}); ok {
dataMap["modified"] = true
ctx.Data = dataMap
}
return nil
}
registry.Register(BeforeCreate, modifyHook)
data := map[string]interface{}{
"name": "test",
}
ctx := &HookContext{
Context: context.Background(),
Schema: "test",
Entity: "users",
Data: data,
}
err := registry.Execute(BeforeCreate, ctx)
if err != nil {
t.Errorf("Hook execution failed: %v", err)
}
modifiedData := ctx.Data.(map[string]interface{})
if !modifiedData["modified"].(bool) {
t.Error("Data was not modified by hook")
}
}
func TestRegisterMultiple(t *testing.T) {
registry := NewHookRegistry()
called := 0
hook := func(ctx *HookContext) error {
called++
return nil
}
registry.RegisterMultiple([]HookType{
BeforeRead,
BeforeCreate,
BeforeUpdate,
}, hook)
if registry.Count(BeforeRead) != 1 {
t.Error("Hook not registered for BeforeRead")
}
if registry.Count(BeforeCreate) != 1 {
t.Error("Hook not registered for BeforeCreate")
}
if registry.Count(BeforeUpdate) != 1 {
t.Error("Hook not registered for BeforeUpdate")
}
ctx := &HookContext{
Context: context.Background(),
Schema: "test",
Entity: "users",
}
registry.Execute(BeforeRead, ctx)
registry.Execute(BeforeCreate, ctx)
registry.Execute(BeforeUpdate, ctx)
if called != 3 {
t.Errorf("Expected hook to be called 3 times, got %d", called)
}
}
func TestClearHooks(t *testing.T) {
registry := NewHookRegistry()
hook := func(ctx *HookContext) error {
return nil
}
registry.Register(BeforeRead, hook)
registry.Register(BeforeCreate, hook)
if registry.Count(BeforeRead) != 1 {
t.Error("Hook not registered")
}
registry.Clear(BeforeRead)
if registry.Count(BeforeRead) != 0 {
t.Error("Hook not cleared")
}
if registry.Count(BeforeCreate) != 1 {
t.Error("Wrong hook was cleared")
}
}
func TestClearAllHooks(t *testing.T) {
registry := NewHookRegistry()
hook := func(ctx *HookContext) error {
return nil
}
registry.Register(BeforeRead, hook)
registry.Register(BeforeCreate, hook)
registry.Register(BeforeUpdate, hook)
registry.ClearAll()
if registry.Count(BeforeRead) != 0 || registry.Count(BeforeCreate) != 0 || registry.Count(BeforeUpdate) != 0 {
t.Error("Not all hooks were cleared")
}
}
func TestHasHooks(t *testing.T) {
registry := NewHookRegistry()
if registry.HasHooks(BeforeRead) {
t.Error("Should not have hooks initially")
}
hook := func(ctx *HookContext) error {
return nil
}
registry.Register(BeforeRead, hook)
if !registry.HasHooks(BeforeRead) {
t.Error("Should have hooks after registration")
}
}
func TestGetAllHookTypes(t *testing.T) {
registry := NewHookRegistry()
hook := func(ctx *HookContext) error {
return nil
}
registry.Register(BeforeRead, hook)
registry.Register(BeforeCreate, hook)
registry.Register(AfterUpdate, hook)
types := registry.GetAllHookTypes()
if len(types) != 3 {
t.Errorf("Expected 3 hook types, got %d", len(types))
}
// Verify all expected types are present
expectedTypes := map[HookType]bool{
BeforeRead: true,
BeforeCreate: true,
AfterUpdate: true,
}
for _, hookType := range types {
if !expectedTypes[hookType] {
t.Errorf("Unexpected hook type: %s", hookType)
}
}
}
func TestHookContextHandler(t *testing.T) {
registry := NewHookRegistry()
var capturedHandler *Handler
hook := func(ctx *HookContext) error {
if ctx.Handler == nil {
return fmt.Errorf("handler is nil in hook context")
}
capturedHandler = ctx.Handler
return nil
}
registry.Register(BeforeRead, hook)
handler := &Handler{
hooks: registry,
}
ctx := &HookContext{
Context: context.Background(),
Handler: handler,
Schema: "test",
Entity: "users",
}
err := registry.Execute(BeforeRead, ctx)
if err != nil {
t.Errorf("Hook execution failed: %v", err)
}
if capturedHandler == nil {
t.Error("Handler was not captured from hook context")
}
if capturedHandler != handler {
t.Error("Captured handler does not match original handler")
}
}
func TestHookAbort(t *testing.T) {
registry := NewHookRegistry()
abortHook := func(ctx *HookContext) error {
ctx.Abort = true
ctx.AbortMessage = "Operation aborted by hook"
ctx.AbortCode = 403
return nil
}
registry.Register(BeforeCreate, abortHook)
ctx := &HookContext{
Context: context.Background(),
Schema: "test",
Entity: "users",
}
err := registry.Execute(BeforeCreate, ctx)
if err == nil {
t.Error("Expected error when hook sets Abort=true")
}
if err.Error() != "operation aborted by hook: Operation aborted by hook" {
t.Errorf("Expected abort error message, got: %v", err)
}
}
func TestHookTypes(t *testing.T) {
// Test all hook type constants
hookTypes := []HookType{
BeforeRead,
AfterRead,
BeforeCreate,
AfterCreate,
BeforeUpdate,
AfterUpdate,
BeforeDelete,
AfterDelete,
BeforeScan,
}
for _, hookType := range hookTypes {
if string(hookType) == "" {
t.Errorf("Hook type should not be empty: %v", hookType)
}
}
}
func TestExecuteWithNoHooks(t *testing.T) {
registry := NewHookRegistry()
ctx := &HookContext{
Context: context.Background(),
Schema: "test",
Entity: "users",
}
// Executing with no registered hooks should not cause an error
err := registry.Execute(BeforeRead, ctx)
if err != nil {
t.Errorf("Execute should not fail with no hooks, got: %v", err)
}
}

View File

@ -0,0 +1,508 @@
// +build integration
package resolvespec
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gorilla/mux"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
// Test models
type TestUser struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
Age int `json:"age"`
Active bool `gorm:"default:true" json:"active"`
CreatedAt time.Time `json:"created_at"`
Posts []TestPost `gorm:"foreignKey:UserID" json:"posts,omitempty"`
}
func (TestUser) TableName() string {
return "test_users"
}
type TestPost struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"user_id"`
Title string `gorm:"not null" json:"title"`
Content string `json:"content"`
Published bool `gorm:"default:false" json:"published"`
CreatedAt time.Time `json:"created_at"`
User *TestUser `gorm:"foreignKey:UserID" json:"user,omitempty"`
Comments []TestComment `gorm:"foreignKey:PostID" json:"comments,omitempty"`
}
func (TestPost) TableName() string {
return "test_posts"
}
type TestComment struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"not null" json:"post_id"`
Content string `gorm:"not null" json:"content"`
CreatedAt time.Time `json:"created_at"`
Post *TestPost `gorm:"foreignKey:PostID" json:"post,omitempty"`
}
func (TestComment) TableName() string {
return "test_comments"
}
// Test helper functions
func setupTestDB(t *testing.T) *gorm.DB {
// Get connection string from environment or use default
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5434 sslmode=disable"
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Skipf("Skipping integration test: database not available: %v", err)
return nil
}
// Run migrations
err = db.AutoMigrate(&TestUser{}, &TestPost{}, &TestComment{})
if err != nil {
t.Skipf("Skipping integration test: failed to migrate database: %v", err)
return nil
}
return db
}
func cleanupTestDB(t *testing.T, db *gorm.DB) {
// Clean up test data
db.Exec("TRUNCATE TABLE test_comments CASCADE")
db.Exec("TRUNCATE TABLE test_posts CASCADE")
db.Exec("TRUNCATE TABLE test_users CASCADE")
}
func createTestData(t *testing.T, db *gorm.DB) {
users := []TestUser{
{Name: "John Doe", Email: "john@example.com", Age: 30, Active: true},
{Name: "Jane Smith", Email: "jane@example.com", Age: 25, Active: true},
{Name: "Bob Johnson", Email: "bob@example.com", Age: 35, Active: false},
}
for i := range users {
if err := db.Create(&users[i]).Error; err != nil {
t.Fatalf("Failed to create test user: %v", err)
}
}
posts := []TestPost{
{UserID: users[0].ID, Title: "First Post", Content: "Hello World", Published: true},
{UserID: users[0].ID, Title: "Second Post", Content: "More content", Published: true},
{UserID: users[1].ID, Title: "Jane's Post", Content: "Jane's content", Published: false},
}
for i := range posts {
if err := db.Create(&posts[i]).Error; err != nil {
t.Fatalf("Failed to create test post: %v", err)
}
}
comments := []TestComment{
{PostID: posts[0].ID, Content: "Great post!"},
{PostID: posts[0].ID, Content: "Thanks for sharing"},
{PostID: posts[1].ID, Content: "Interesting"},
}
for i := range comments {
if err := db.Create(&comments[i]).Error; err != nil {
t.Fatalf("Failed to create test comment: %v", err)
}
}
}
// Integration tests
func TestIntegration_CreateOperation(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
handler := NewHandlerWithGORM(db)
handler.RegisterModel("public", "test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
// Create a new user
requestBody := map[string]interface{}{
"operation": "create",
"data": map[string]interface{}{
"name": "Test User",
"email": "test@example.com",
"age": 28,
},
}
body, _ := json.Marshal(requestBody)
req := httptest.NewRequest("POST", "/public/test_users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v. Error: %v", response.Success, response.Error)
}
// Verify user was created
var user TestUser
if err := db.Where("email = ?", "test@example.com").First(&user).Error; err != nil {
t.Errorf("Failed to find created user: %v", err)
}
if user.Name != "Test User" {
t.Errorf("Expected name 'Test User', got '%s'", user.Name)
}
}
func TestIntegration_ReadOperation(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.RegisterModel("public", "test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
// Read all users
requestBody := map[string]interface{}{
"operation": "read",
"options": map[string]interface{}{
"limit": 10,
},
}
body, _ := json.Marshal(requestBody)
req := httptest.NewRequest("POST", "/public/test_users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
if response.Metadata == nil {
t.Fatal("Expected metadata, got nil")
}
if response.Metadata.Total != 3 {
t.Errorf("Expected 3 users, got %d", response.Metadata.Total)
}
}
func TestIntegration_ReadWithFilters(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.RegisterModel("public", "test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
// Read users with age > 25
requestBody := map[string]interface{}{
"operation": "read",
"options": map[string]interface{}{
"filters": []map[string]interface{}{
{
"column": "age",
"operator": "gt",
"value": 25,
},
},
},
}
body, _ := json.Marshal(requestBody)
req := httptest.NewRequest("POST", "/public/test_users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
// Should return 2 users (John: 30, Bob: 35)
if response.Metadata.Total != 2 {
t.Errorf("Expected 2 filtered users, got %d", response.Metadata.Total)
}
}
func TestIntegration_UpdateOperation(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.RegisterModel("public", "test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
// Get user ID
var user TestUser
db.Where("email = ?", "john@example.com").First(&user)
// Update user
requestBody := map[string]interface{}{
"operation": "update",
"data": map[string]interface{}{
"id": user.ID,
"age": 31,
"name": "John Doe Updated",
},
}
body, _ := json.Marshal(requestBody)
req := httptest.NewRequest("POST", fmt.Sprintf("/public/test_users/%d", user.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify update
var updatedUser TestUser
db.First(&updatedUser, user.ID)
if updatedUser.Age != 31 {
t.Errorf("Expected age 31, got %d", updatedUser.Age)
}
if updatedUser.Name != "John Doe Updated" {
t.Errorf("Expected name 'John Doe Updated', got '%s'", updatedUser.Name)
}
}
func TestIntegration_DeleteOperation(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.RegisterModel("public", "test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
// Get user ID
var user TestUser
db.Where("email = ?", "bob@example.com").First(&user)
// Delete user
requestBody := map[string]interface{}{
"operation": "delete",
}
body, _ := json.Marshal(requestBody)
req := httptest.NewRequest("POST", fmt.Sprintf("/public/test_users/%d", user.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify deletion
var count int64
db.Model(&TestUser{}).Where("id = ?", user.ID).Count(&count)
if count != 0 {
t.Errorf("Expected user to be deleted, but found %d records", count)
}
}
func TestIntegration_MetadataOperation(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
handler := NewHandlerWithGORM(db)
handler.RegisterModel("public", "test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
// Get metadata
requestBody := map[string]interface{}{
"operation": "meta",
}
body, _ := json.Marshal(requestBody)
req := httptest.NewRequest("POST", "/public/test_users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
// Check that metadata includes columns
// The response.Data is an interface{}, we need to unmarshal it properly
dataBytes, _ := json.Marshal(response.Data)
var metadata common.TableMetadata
if err := json.Unmarshal(dataBytes, &metadata); err != nil {
t.Fatalf("Failed to unmarshal metadata: %v. Raw data: %+v", err, response.Data)
}
if len(metadata.Columns) == 0 {
t.Error("Expected metadata to contain columns")
}
// Verify some expected columns
hasID := false
hasName := false
hasEmail := false
for _, col := range metadata.Columns {
if col.Name == "id" {
hasID = true
if !col.IsPrimary {
t.Error("Expected 'id' column to be primary key")
}
}
if col.Name == "name" {
hasName = true
}
if col.Name == "email" {
hasEmail = true
}
}
if !hasID || !hasName || !hasEmail {
t.Error("Expected metadata to contain 'id', 'name', and 'email' columns")
}
}
func TestIntegration_ReadWithPreload(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.RegisterModel("public", "test_users", TestUser{})
handler.RegisterModel("public", "test_posts", TestPost{})
handler.RegisterModel("public", "test_comments", TestComment{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
// Read users with posts preloaded
requestBody := map[string]interface{}{
"operation": "read",
"options": map[string]interface{}{
"filters": []map[string]interface{}{
{
"column": "email",
"operator": "eq",
"value": "john@example.com",
},
},
"preload": []map[string]interface{}{
{"relation": "posts"},
},
},
}
body, _ := json.Marshal(requestBody)
req := httptest.NewRequest("POST", "/public/test_users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
// Verify posts are preloaded
dataBytes, _ := json.Marshal(response.Data)
var users []TestUser
json.Unmarshal(dataBytes, &users)
if len(users) == 0 {
t.Fatal("Expected at least one user")
}
if len(users[0].Posts) == 0 {
t.Error("Expected posts to be preloaded")
}
}

View File

@ -0,0 +1,114 @@
package resolvespec
import (
"testing"
)
func TestParseModelName(t *testing.T) {
tests := []struct {
name string
fullName string
expectedSchema string
expectedEntity string
}{
{
name: "Model with schema",
fullName: "public.users",
expectedSchema: "public",
expectedEntity: "users",
},
{
name: "Model without schema",
fullName: "users",
expectedSchema: "",
expectedEntity: "users",
},
{
name: "Model with custom schema",
fullName: "myschema.products",
expectedSchema: "myschema",
expectedEntity: "products",
},
{
name: "Empty string",
fullName: "",
expectedSchema: "",
expectedEntity: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schema, entity := parseModelName(tt.fullName)
if schema != tt.expectedSchema {
t.Errorf("Expected schema '%s', got '%s'", tt.expectedSchema, schema)
}
if entity != tt.expectedEntity {
t.Errorf("Expected entity '%s', got '%s'", tt.expectedEntity, entity)
}
})
}
}
func TestBuildRoutePath(t *testing.T) {
tests := []struct {
name string
schema string
entity string
expectedPath string
}{
{
name: "With schema",
schema: "public",
entity: "users",
expectedPath: "/public/users",
},
{
name: "Without schema",
schema: "",
entity: "users",
expectedPath: "/users",
},
{
name: "Custom schema",
schema: "admin",
entity: "logs",
expectedPath: "/admin/logs",
},
{
name: "Empty entity with schema",
schema: "public",
entity: "",
expectedPath: "/public/",
},
{
name: "Both empty",
schema: "",
entity: "",
expectedPath: "/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := buildRoutePath(tt.schema, tt.entity)
if path != tt.expectedPath {
t.Errorf("Expected path '%s', got '%s'", tt.expectedPath, path)
}
})
}
}
func TestNewStandardMuxRouter(t *testing.T) {
router := NewStandardMuxRouter()
if router == nil {
t.Error("Expected router to be created, got nil")
}
}
func TestNewStandardBunRouter(t *testing.T) {
router := NewStandardBunRouter()
if router == nil {
t.Error("Expected router to be created, got nil")
}
}

View File

@ -0,0 +1,181 @@
package restheadspec
import (
"context"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
func TestContextOperations(t *testing.T) {
ctx := context.Background()
// Test Schema
t.Run("WithSchema and GetSchema", func(t *testing.T) {
ctx = WithSchema(ctx, "public")
schema := GetSchema(ctx)
if schema != "public" {
t.Errorf("Expected schema 'public', got '%s'", schema)
}
})
// Test Entity
t.Run("WithEntity and GetEntity", func(t *testing.T) {
ctx = WithEntity(ctx, "users")
entity := GetEntity(ctx)
if entity != "users" {
t.Errorf("Expected entity 'users', got '%s'", entity)
}
})
// Test TableName
t.Run("WithTableName and GetTableName", func(t *testing.T) {
ctx = WithTableName(ctx, "public.users")
tableName := GetTableName(ctx)
if tableName != "public.users" {
t.Errorf("Expected tableName 'public.users', got '%s'", tableName)
}
})
// Test Model
t.Run("WithModel and GetModel", func(t *testing.T) {
type TestModel struct {
ID int
Name string
}
model := &TestModel{ID: 1, Name: "test"}
ctx = WithModel(ctx, model)
retrieved := GetModel(ctx)
if retrieved == nil {
t.Error("Expected model to be retrieved, got nil")
}
if retrievedModel, ok := retrieved.(*TestModel); ok {
if retrievedModel.ID != 1 || retrievedModel.Name != "test" {
t.Errorf("Expected model with ID=1 and Name='test', got ID=%d, Name='%s'", retrievedModel.ID, retrievedModel.Name)
}
} else {
t.Error("Retrieved model is not of expected type")
}
})
// Test ModelPtr
t.Run("WithModelPtr and GetModelPtr", func(t *testing.T) {
type TestModel struct {
ID int
}
models := []*TestModel{}
ctx = WithModelPtr(ctx, &models)
retrieved := GetModelPtr(ctx)
if retrieved == nil {
t.Error("Expected modelPtr to be retrieved, got nil")
}
})
// Test Options
t.Run("WithOptions and GetOptions", func(t *testing.T) {
limit := 10
options := ExtendedRequestOptions{
RequestOptions: common.RequestOptions{
Limit: &limit,
},
}
ctx = WithOptions(ctx, options)
retrieved := GetOptions(ctx)
if retrieved == nil {
t.Error("Expected options to be retrieved, got nil")
return
}
if retrieved.Limit == nil || *retrieved.Limit != 10 {
t.Error("Expected options to be retrieved with limit=10")
}
})
// Test WithRequestData
t.Run("WithRequestData", func(t *testing.T) {
type TestModel struct {
ID int
Name string
}
model := &TestModel{ID: 1, Name: "test"}
modelPtr := &[]*TestModel{}
limit := 20
options := ExtendedRequestOptions{
RequestOptions: common.RequestOptions{
Limit: &limit,
},
}
ctx = WithRequestData(ctx, "test_schema", "test_entity", "test_schema.test_entity", model, modelPtr, options)
if GetSchema(ctx) != "test_schema" {
t.Errorf("Expected schema 'test_schema', got '%s'", GetSchema(ctx))
}
if GetEntity(ctx) != "test_entity" {
t.Errorf("Expected entity 'test_entity', got '%s'", GetEntity(ctx))
}
if GetTableName(ctx) != "test_schema.test_entity" {
t.Errorf("Expected tableName 'test_schema.test_entity', got '%s'", GetTableName(ctx))
}
if GetModel(ctx) == nil {
t.Error("Expected model to be set")
}
if GetModelPtr(ctx) == nil {
t.Error("Expected modelPtr to be set")
}
opts := GetOptions(ctx)
if opts == nil {
t.Error("Expected options to be set")
return
}
if opts.Limit == nil || *opts.Limit != 20 {
t.Error("Expected options to be set with limit=20")
}
})
}
func TestEmptyContext(t *testing.T) {
ctx := context.Background()
t.Run("GetSchema with empty context", func(t *testing.T) {
schema := GetSchema(ctx)
if schema != "" {
t.Errorf("Expected empty schema, got '%s'", schema)
}
})
t.Run("GetEntity with empty context", func(t *testing.T) {
entity := GetEntity(ctx)
if entity != "" {
t.Errorf("Expected empty entity, got '%s'", entity)
}
})
t.Run("GetTableName with empty context", func(t *testing.T) {
tableName := GetTableName(ctx)
if tableName != "" {
t.Errorf("Expected empty tableName, got '%s'", tableName)
}
})
t.Run("GetModel with empty context", func(t *testing.T) {
model := GetModel(ctx)
if model != nil {
t.Errorf("Expected nil model, got %v", model)
}
})
t.Run("GetModelPtr with empty context", func(t *testing.T) {
modelPtr := GetModelPtr(ctx)
if modelPtr != nil {
t.Errorf("Expected nil modelPtr, got %v", modelPtr)
}
})
t.Run("GetOptions with empty context", func(t *testing.T) {
options := GetOptions(ctx)
// GetOptions returns nil when context is empty
if options != nil {
t.Errorf("Expected nil options in empty context, got %v", options)
}
})
}

View File

@ -226,8 +226,20 @@ func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params ma
return return
} }
metadata := h.generateMetadata(schema, entity, model) // Parse request options from headers to get response format settings
h.sendResponse(w, metadata, nil) options := h.parseOptionsFromHeaders(r, model)
tableMetadata := h.generateMetadata(schema, entity, model)
// Send with formatted response to respect DetailApi/SimpleApi/Syncfusion format
// Create empty metadata for response wrapper
responseMetadata := &common.Metadata{
Total: 0,
Filtered: 0,
Count: 0,
Limit: 0,
Offset: 0,
}
h.sendFormattedResponse(w, tableMetadata, responseMetadata, options)
} }
// handleMeta processes meta operation requests // handleMeta processes meta operation requests

View File

@ -1,3 +1,5 @@
// +build !integration
package restheadspec package restheadspec
import ( import (

View File

@ -0,0 +1,46 @@
package restheadspec
import (
"testing"
)
func TestDecodeHeaderValue(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "Normal string",
input: "test",
expected: "test",
},
{
name: "String without encoding prefix",
input: "hello world",
expected: "hello world",
},
{
name: "Empty string",
input: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := decodeHeaderValue(tt.input)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
// Note: The following functions are unexported (lowercase) and cannot be tested directly:
// - parseSelectFields
// - parseFieldFilter
// - mapSearchOperator
// - parseCommaSeparated
// - parseSorting
// These are tested indirectly through parseOptionsFromHeaders in query_params_test.go

View File

@ -0,0 +1,556 @@
// +build integration
package restheadspec
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gorilla/mux"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
// Test models
type TestUser struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
Age int `json:"age"`
Active bool `gorm:"default:true" json:"active"`
CreatedAt time.Time `json:"created_at"`
Posts []TestPost `gorm:"foreignKey:UserID" json:"posts,omitempty"`
}
func (TestUser) TableName() string {
return "test_users"
}
type TestPost struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"user_id"`
Title string `gorm:"not null" json:"title"`
Content string `json:"content"`
Published bool `gorm:"default:false" json:"published"`
CreatedAt time.Time `json:"created_at"`
User *TestUser `gorm:"foreignKey:UserID" json:"user,omitempty"`
Comments []TestComment `gorm:"foreignKey:PostID" json:"comments,omitempty"`
}
func (TestPost) TableName() string {
return "test_posts"
}
type TestComment struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"not null" json:"post_id"`
Content string `gorm:"not null" json:"content"`
CreatedAt time.Time `json:"created_at"`
Post *TestPost `gorm:"foreignKey:PostID" json:"post,omitempty"`
}
func (TestComment) TableName() string {
return "test_comments"
}
// Test helper functions
func setupTestDB(t *testing.T) *gorm.DB {
// Get connection string from environment or use default
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = "host=localhost user=postgres password=postgres dbname=restheadspec_test port=5434 sslmode=disable"
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Skipf("Skipping integration test: database not available: %v", err)
return nil
}
// Run migrations
err = db.AutoMigrate(&TestUser{}, &TestPost{}, &TestComment{})
if err != nil {
t.Skipf("Skipping integration test: failed to migrate database: %v", err)
return nil
}
return db
}
func cleanupTestDB(t *testing.T, db *gorm.DB) {
// Clean up test data
db.Exec("TRUNCATE TABLE test_comments CASCADE")
db.Exec("TRUNCATE TABLE test_posts CASCADE")
db.Exec("TRUNCATE TABLE test_users CASCADE")
}
func createTestData(t *testing.T, db *gorm.DB) {
users := []TestUser{
{Name: "John Doe", Email: "john@example.com", Age: 30, Active: true},
{Name: "Jane Smith", Email: "jane@example.com", Age: 25, Active: true},
{Name: "Bob Johnson", Email: "bob@example.com", Age: 35, Active: false},
}
for i := range users {
if err := db.Create(&users[i]).Error; err != nil {
t.Fatalf("Failed to create test user: %v", err)
}
}
posts := []TestPost{
{UserID: users[0].ID, Title: "First Post", Content: "Hello World", Published: true},
{UserID: users[0].ID, Title: "Second Post", Content: "More content", Published: true},
{UserID: users[1].ID, Title: "Jane's Post", Content: "Jane's content", Published: false},
}
for i := range posts {
if err := db.Create(&posts[i]).Error; err != nil {
t.Fatalf("Failed to create test post: %v", err)
}
}
comments := []TestComment{
{PostID: posts[0].ID, Content: "Great post!"},
{PostID: posts[0].ID, Content: "Thanks for sharing"},
{PostID: posts[1].ID, Content: "Interesting"},
}
for i := range comments {
if err := db.Create(&comments[i]).Error; err != nil {
t.Fatalf("Failed to create test comment: %v", err)
}
}
}
// Integration tests
func TestIntegration_GetAllUsers(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.registry.RegisterModel("public.test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
req := httptest.NewRequest("GET", "/public/test_users", nil)
req.Header.Set("X-DetailApi", "true")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
if response.Metadata == nil {
t.Fatal("Expected metadata, got nil")
}
if response.Metadata.Total != 3 {
t.Errorf("Expected 3 users, got %d", response.Metadata.Total)
}
}
func TestIntegration_GetUsersWithFilters(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.registry.RegisterModel("public.test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
// Filter: age > 25
req := httptest.NewRequest("GET", "/public/test_users", nil)
req.Header.Set("X-DetailApi", "true")
req.Header.Set("X-SearchOp-Gt-Age", "25")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
// Should return 2 users (John: 30, Bob: 35)
if response.Metadata.Total != 2 {
t.Errorf("Expected 2 filtered users, got %d", response.Metadata.Total)
}
}
func TestIntegration_GetUsersWithPagination(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.registry.RegisterModel("public.test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
req := httptest.NewRequest("GET", "/public/test_users", nil)
req.Header.Set("X-DetailApi", "true")
req.Header.Set("X-Limit", "2")
req.Header.Set("X-Offset", "1")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
// Total should still be 3, but we're only retrieving 2 records starting from offset 1
if response.Metadata.Total != 3 {
t.Errorf("Expected total 3 users, got %d", response.Metadata.Total)
}
if response.Metadata.Limit != 2 {
t.Errorf("Expected limit 2, got %d", response.Metadata.Limit)
}
if response.Metadata.Offset != 1 {
t.Errorf("Expected offset 1, got %d", response.Metadata.Offset)
}
}
func TestIntegration_GetUsersWithSorting(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.registry.RegisterModel("public.test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
// Sort by age descending
req := httptest.NewRequest("GET", "/public/test_users", nil)
req.Header.Set("X-DetailApi", "true")
req.Header.Set("X-Sort", "-age")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
// Parse data to verify sort order
dataBytes, _ := json.Marshal(response.Data)
var users []TestUser
json.Unmarshal(dataBytes, &users)
if len(users) < 3 {
t.Fatal("Expected at least 3 users")
}
// Check that users are sorted by age descending (Bob:35, John:30, Jane:25)
if users[0].Age < users[1].Age || users[1].Age < users[2].Age {
t.Error("Expected users to be sorted by age descending")
}
}
func TestIntegration_GetUsersWithColumnsSelection(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.registry.RegisterModel("public.test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
req := httptest.NewRequest("GET", "/public/test_users", nil)
req.Header.Set("X-DetailApi", "true")
req.Header.Set("X-Columns", "id,name,email")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
// Verify data was returned (column selection doesn't affect metadata count)
if response.Metadata.Total != 3 {
t.Errorf("Expected 3 users, got %d", response.Metadata.Total)
}
}
func TestIntegration_GetUsersWithPreload(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.registry.RegisterModel("public.test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
req := httptest.NewRequest("GET", "/public/test_users?x-fieldfilter-email=john@example.com", nil)
req.Header.Set("X-DetailApi", "true")
req.Header.Set("X-Preload", "Posts")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
// Verify posts are preloaded
dataBytes, _ := json.Marshal(response.Data)
var users []TestUser
json.Unmarshal(dataBytes, &users)
if len(users) == 0 {
t.Fatal("Expected at least one user")
}
if len(users[0].Posts) == 0 {
t.Error("Expected posts to be preloaded")
}
}
func TestIntegration_GetMetadata(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
handler := NewHandlerWithGORM(db)
handler.registry.RegisterModel("public.test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
req := httptest.NewRequest("GET", "/public/test_users/metadata", nil)
req.Header.Set("X-DetailApi", "true")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v. Body: %s, Error: %v", response.Success, w.Body.String(), response.Error)
}
// Check that metadata includes columns
metadataBytes, _ := json.Marshal(response.Data)
var metadata common.TableMetadata
json.Unmarshal(metadataBytes, &metadata)
if len(metadata.Columns) == 0 {
t.Error("Expected metadata to contain columns")
}
// Verify some expected columns
hasID := false
hasName := false
hasEmail := false
for _, col := range metadata.Columns {
if col.Name == "id" {
hasID = true
}
if col.Name == "name" {
hasName = true
}
if col.Name == "email" {
hasEmail = true
}
}
if !hasID || !hasName || !hasEmail {
t.Error("Expected metadata to contain 'id', 'name', and 'email' columns")
}
}
func TestIntegration_OptionsRequest(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
handler := NewHandlerWithGORM(db)
handler.registry.RegisterModel("public.test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
req := httptest.NewRequest("OPTIONS", "/public/test_users", nil)
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check CORS headers
if w.Header().Get("Access-Control-Allow-Origin") == "" {
t.Error("Expected Access-Control-Allow-Origin header")
}
if w.Header().Get("Access-Control-Allow-Methods") == "" {
t.Error("Expected Access-Control-Allow-Methods header")
}
}
func TestIntegration_QueryParamsOverHeaders(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.registry.RegisterModel("public.test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
// Query params should override headers
req := httptest.NewRequest("GET", "/public/test_users?x-limit=1", nil)
req.Header.Set("X-DetailApi", "true")
req.Header.Set("X-Limit", "10")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
// Query param should win (limit=1)
if response.Metadata.Limit != 1 {
t.Errorf("Expected limit 1 from query param, got %d", response.Metadata.Limit)
}
}
func TestIntegration_GetSingleRecord(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
createTestData(t, db)
handler := NewHandlerWithGORM(db)
handler.registry.RegisterModel("public.test_users", TestUser{})
muxRouter := mux.NewRouter()
SetupMuxRoutes(muxRouter, handler, nil)
// Get first user ID
var user TestUser
db.Where("email = ?", "john@example.com").First(&user)
req := httptest.NewRequest("GET", fmt.Sprintf("/public/test_users/%d", user.ID), nil)
req.Header.Set("X-DetailApi", "true")
w := httptest.NewRecorder()
muxRouter.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var response common.Response
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got %v", response.Success)
}
// Verify it's a single record
dataBytes, _ := json.Marshal(response.Data)
var resultUser TestUser
json.Unmarshal(dataBytes, &resultUser)
if resultUser.Email != "john@example.com" {
t.Errorf("Expected user with email 'john@example.com', got '%s'", resultUser.Email)
}
}

View File

@ -128,15 +128,17 @@ func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware Midd
} }
// Register routes for this entity // Register routes for this entity
// IMPORTANT: Register more specific routes before wildcard routes
// GET, POST for /{schema}/{entity} // GET, POST for /{schema}/{entity}
muxRouter.Handle(entityPath, entityHandler).Methods("GET", "POST") muxRouter.Handle(entityPath, entityHandler).Methods("GET", "POST")
// GET for metadata (using HandleGet) - MUST be registered before /{id} route
muxRouter.Handle(metadataPath, metadataHandler).Methods("GET")
// GET, PUT, PATCH, DELETE, POST for /{schema}/{entity}/{id} // GET, PUT, PATCH, DELETE, POST for /{schema}/{entity}/{id}
muxRouter.Handle(entityWithIDPath, entityWithIDHandler).Methods("GET", "PUT", "PATCH", "DELETE", "POST") muxRouter.Handle(entityWithIDPath, entityWithIDHandler).Methods("GET", "PUT", "PATCH", "DELETE", "POST")
// GET for metadata (using HandleGet)
muxRouter.Handle(metadataPath, metadataHandler).Methods("GET")
// OPTIONS for CORS preflight - returns metadata // OPTIONS for CORS preflight - returns metadata
muxRouter.Handle(entityPath, optionsEntityHandler).Methods("OPTIONS") muxRouter.Handle(entityPath, optionsEntityHandler).Methods("OPTIONS")
muxRouter.Handle(entityWithIDPath, optionsEntityWithIDHandler).Methods("OPTIONS") muxRouter.Handle(entityWithIDPath, optionsEntityWithIDHandler).Methods("OPTIONS")

View File

@ -0,0 +1,114 @@
package restheadspec
import (
"testing"
)
func TestParseModelName(t *testing.T) {
tests := []struct {
name string
fullName string
expectedSchema string
expectedEntity string
}{
{
name: "Model with schema",
fullName: "public.users",
expectedSchema: "public",
expectedEntity: "users",
},
{
name: "Model without schema",
fullName: "users",
expectedSchema: "",
expectedEntity: "users",
},
{
name: "Model with custom schema",
fullName: "myschema.products",
expectedSchema: "myschema",
expectedEntity: "products",
},
{
name: "Empty string",
fullName: "",
expectedSchema: "",
expectedEntity: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schema, entity := parseModelName(tt.fullName)
if schema != tt.expectedSchema {
t.Errorf("Expected schema '%s', got '%s'", tt.expectedSchema, schema)
}
if entity != tt.expectedEntity {
t.Errorf("Expected entity '%s', got '%s'", tt.expectedEntity, entity)
}
})
}
}
func TestBuildRoutePath(t *testing.T) {
tests := []struct {
name string
schema string
entity string
expectedPath string
}{
{
name: "With schema",
schema: "public",
entity: "users",
expectedPath: "/public/users",
},
{
name: "Without schema",
schema: "",
entity: "users",
expectedPath: "/users",
},
{
name: "Custom schema",
schema: "admin",
entity: "logs",
expectedPath: "/admin/logs",
},
{
name: "Empty entity with schema",
schema: "public",
entity: "",
expectedPath: "/public/",
},
{
name: "Both empty",
schema: "",
entity: "",
expectedPath: "/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := buildRoutePath(tt.schema, tt.entity)
if path != tt.expectedPath {
t.Errorf("Expected path '%s', got '%s'", tt.expectedPath, path)
}
})
}
}
func TestNewStandardMuxRouter(t *testing.T) {
router := NewStandardMuxRouter()
if router == nil {
t.Error("Expected router to be created, got nil")
}
}
func TestNewStandardBunRouter(t *testing.T) {
router := NewStandardBunRouter()
if router == nil {
t.Error("Expected router to be created, got nil")
}
}

View File

@ -0,0 +1,7 @@
-- Create test databases for integration tests
CREATE DATABASE resolvespec_test;
CREATE DATABASE restheadspec_test;
-- Grant all privileges to postgres user
GRANT ALL PRIVILEGES ON DATABASE resolvespec_test TO postgres;
GRANT ALL PRIVILEGES ON DATABASE restheadspec_test TO postgres;

View File

@ -0,0 +1,84 @@
#!/bin/bash
# Script to run integration tests with automatic PostgreSQL setup
# Usage: ./scripts/run-integration-tests.sh [package]
# package: optional, can be "resolvespec", "restheadspec", or omit for both
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}=== ResolveSpec Integration Tests ===${NC}\n"
# Check if docker-compose is available
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}Error: docker-compose is not installed${NC}"
echo "Please install docker-compose or run PostgreSQL manually"
echo "See INTEGRATION_TESTS.md for details"
exit 1
fi
# Clean up any existing containers and networks from previous runs
echo -e "${YELLOW}Cleaning up existing containers and networks...${NC}"
docker-compose down -v 2>/dev/null || true
# Start PostgreSQL
echo -e "${YELLOW}Starting PostgreSQL...${NC}"
docker-compose up -d postgres-test
# Wait for PostgreSQL to be ready
echo -e "${YELLOW}Waiting for PostgreSQL to be ready...${NC}"
max_attempts=30
attempt=0
while ! docker-compose exec -T postgres-test pg_isready -U postgres > /dev/null 2>&1; do
attempt=$((attempt + 1))
if [ $attempt -ge $max_attempts ]; then
echo -e "${RED}Error: PostgreSQL failed to start after ${max_attempts} seconds${NC}"
docker-compose logs postgres-test
docker-compose down
exit 1
fi
sleep 1
echo -n "."
done
echo -e "\n${GREEN}PostgreSQL is ready!${NC}\n"
# Create test databases
echo -e "${YELLOW}Creating test databases...${NC}"
docker-compose exec -T postgres-test psql -U postgres -c "CREATE DATABASE resolvespec_test;" 2>/dev/null || echo " resolvespec_test already exists"
docker-compose exec -T postgres-test psql -U postgres -c "CREATE DATABASE restheadspec_test;" 2>/dev/null || echo " restheadspec_test already exists"
echo -e "${GREEN}Test databases ready!${NC}\n"
# Determine which tests to run
PACKAGE=""
if [ "$1" == "resolvespec" ]; then
PACKAGE="./pkg/resolvespec"
echo -e "${YELLOW}Running resolvespec integration tests...${NC}\n"
elif [ "$1" == "restheadspec" ]; then
PACKAGE="./pkg/restheadspec"
echo -e "${YELLOW}Running restheadspec integration tests...${NC}\n"
else
PACKAGE="./pkg/resolvespec ./pkg/restheadspec"
echo -e "${YELLOW}Running all integration tests...${NC}\n"
fi
# Run tests
if go test -tags=integration $PACKAGE -v; then
echo -e "\n${GREEN}✓ All integration tests passed!${NC}"
EXIT_CODE=0
else
echo -e "\n${RED}✗ Some integration tests failed${NC}"
EXIT_CODE=1
fi
# Cleanup
echo -e "\n${YELLOW}Stopping PostgreSQL...${NC}"
docker-compose down
exit $EXIT_CODE

265
tests/INTEGRATION_TESTS.md Normal file
View File

@ -0,0 +1,265 @@
# Integration Tests
This document describes how to run integration tests for ResolveSpec packages with a PostgreSQL database.
## Overview
Integration tests validate the full functionality of both `pkg/resolvespec` and `pkg/restheadspec` packages with an actual PostgreSQL database. These tests cover:
- CRUD operations (Create, Read, Update, Delete)
- Filtering and sorting
- Pagination
- Column selection
- Relationship preloading
- Metadata generation
- Query parameter parsing
- CORS handling
## Prerequisites
- Go 1.19 or later
- PostgreSQL 12 or later
- Docker and Docker Compose (optional, for easy setup)
## Quick Start with Docker
### 1. Start PostgreSQL with Docker Compose
```bash
docker-compose up -d postgres-test
```
This starts a PostgreSQL container with the following default settings:
- Host: localhost
- Port: 5432
- User: postgres
- Password: postgres
- Databases: resolvespec_test, restheadspec_test
### 2. Run Integration Tests
```bash
# Run all integration tests
go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v
# Run only resolvespec integration tests
go test -tags=integration ./pkg/resolvespec -v
# Run only restheadspec integration tests
go test -tags=integration ./pkg/restheadspec -v
```
### 3. Stop PostgreSQL
```bash
docker-compose down
```
## Manual PostgreSQL Setup
If you prefer to use an existing PostgreSQL installation:
### 1. Create Test Databases
```sql
CREATE DATABASE resolvespec_test;
CREATE DATABASE restheadspec_test;
```
### 2. Set Environment Variable
```bash
# For resolvespec tests
export TEST_DATABASE_URL="host=localhost user=postgres password=yourpassword dbname=resolvespec_test port=5432 sslmode=disable"
# For restheadspec tests (uses same env var with different dbname)
export TEST_DATABASE_URL="host=localhost user=postgres password=yourpassword dbname=restheadspec_test port=5432 sslmode=disable"
```
### 3. Run Tests
```bash
go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v
```
## Test Coverage
### pkg/resolvespec Integration Tests
| Test | Description |
|------|-------------|
| `TestIntegration_CreateOperation` | Tests creating new records via API |
| `TestIntegration_ReadOperation` | Tests reading all records with pagination |
| `TestIntegration_ReadWithFilters` | Tests filtering records (e.g., age > 25) |
| `TestIntegration_UpdateOperation` | Tests updating existing records |
| `TestIntegration_DeleteOperation` | Tests deleting records |
| `TestIntegration_MetadataOperation` | Tests retrieving table metadata |
| `TestIntegration_ReadWithPreload` | Tests eager loading relationships |
### pkg/restheadspec Integration Tests
| Test | Description |
|------|-------------|
| `TestIntegration_GetAllUsers` | Tests GET request to retrieve all records |
| `TestIntegration_GetUsersWithFilters` | Tests header-based filtering |
| `TestIntegration_GetUsersWithPagination` | Tests limit/offset pagination |
| `TestIntegration_GetUsersWithSorting` | Tests sorting by column |
| `TestIntegration_GetUsersWithColumnsSelection` | Tests selecting specific columns |
| `TestIntegration_GetUsersWithPreload` | Tests relationship preloading |
| `TestIntegration_GetMetadata` | Tests metadata endpoint |
| `TestIntegration_OptionsRequest` | Tests OPTIONS/CORS handling |
| `TestIntegration_QueryParamsOverHeaders` | Tests query param precedence |
| `TestIntegration_GetSingleRecord` | Tests retrieving single record by ID |
## Test Data
Integration tests use the following test models:
### TestUser
```go
type TestUser struct {
ID uint
Name string
Email string (unique)
Age int
Active bool
CreatedAt time.Time
Posts []TestPost
}
```
### TestPost
```go
type TestPost struct {
ID uint
UserID uint
Title string
Content string
Published bool
CreatedAt time.Time
User *TestUser
Comments []TestComment
}
```
### TestComment
```go
type TestComment struct {
ID uint
PostID uint
Content string
CreatedAt time.Time
Post *TestPost
}
```
## Troubleshooting
### Connection Refused
If you see "connection refused" errors:
1. Check that PostgreSQL is running:
```bash
docker-compose ps
```
2. Verify connection parameters:
```bash
psql -h localhost -U postgres -d resolvespec_test
```
3. Check firewall settings if using remote PostgreSQL
### Permission Denied
Ensure the PostgreSQL user has necessary permissions:
```sql
GRANT ALL PRIVILEGES ON DATABASE resolvespec_test TO postgres;
GRANT ALL PRIVILEGES ON DATABASE restheadspec_test TO postgres;
```
### Tests Fail with "relation does not exist"
The tests automatically run migrations, but if you encounter this error:
1. Ensure your DATABASE_URL environment variable is correct
2. Check that the database exists
3. Verify the user has CREATE TABLE permissions
### Clean Database Between Runs
Each test automatically cleans up its data using `TRUNCATE`. If you need a fresh database:
```bash
# Stop and remove containers (removes data)
docker-compose down -v
# Restart
docker-compose up -d postgres-test
```
## CI/CD Integration
### GitHub Actions Example
```yaml
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: resolvespec_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Run integration tests
env:
TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
run: |
go test -tags=integration ./pkg/resolvespec -v
go test -tags=integration ./pkg/restheadspec -v
```
## Performance Considerations
- Integration tests are slower than unit tests due to database I/O
- Each test sets up and tears down test data
- Consider running integration tests separately from unit tests in CI/CD
- Use connection pooling for better performance
## Best Practices
1. **Isolation**: Each test cleans up its data using TRUNCATE
2. **Independent**: Tests don't depend on each other's state
3. **Idempotent**: Tests can be run multiple times safely
4. **Fast Setup**: Migrations run automatically
5. **Flexible**: Works with any PostgreSQL instance via environment variables
## Additional Resources
- [PostgreSQL Docker Image](https://hub.docker.com/_/postgres)
- [GORM Documentation](https://gorm.io/)
- [Testing in Go](https://golang.org/doc/tutorial/add-a-test)

135
tests/QUICKSTART_TESTING.md Normal file
View File

@ -0,0 +1,135 @@
# Testing Quick Start
## ⚡ 30-Second Start
```bash
# Unit tests (no setup required)
go test ./pkg/resolvespec ./pkg/restheadspec -v
# Integration tests (automated)
./scripts/run-integration-tests.sh
```
## 📋 Common Commands
| What You Want | Command |
|---------------|---------|
| Run unit tests | `make test-unit` |
| Run integration tests | `./scripts/run-integration-tests.sh` |
| Run all tests | `make test` |
| Coverage report | `make coverage` |
| Start PostgreSQL | `make docker-up` |
| Stop PostgreSQL | `make docker-down` |
| See all commands | `make help` |
## 📊 Current Test Coverage
- **pkg/resolvespec**: 11.2% (28 unit + 7 integration tests)
- **pkg/restheadspec**: 12.5% (50 unit + 10 integration tests)
- **Total**: 95 tests
## 🧪 Test Types
### Unit Tests (Fast, No Database)
Test individual functions and components in isolation.
```bash
go test ./pkg/resolvespec -v
go test ./pkg/restheadspec -v
```
### Integration Tests (Requires PostgreSQL)
Test full API operations with real database.
```bash
# Automated (recommended)
./scripts/run-integration-tests.sh
# Manual
make docker-up
go test -tags=integration ./pkg/resolvespec -v
make docker-down
```
## 🔍 Run Specific Tests
```bash
# Run a specific test function
go test ./pkg/resolvespec -run TestHookRegistry -v
# Run tests matching a pattern
go test ./pkg/resolvespec -run "TestHook.*" -v
# Run integration test for specific feature
go test -tags=integration ./pkg/restheadspec -run TestIntegration_GetUsersWithFilters -v
```
## 📈 Coverage Reports
```bash
# Generate HTML coverage report
make coverage
# View in terminal
go test ./pkg/resolvespec -cover
```
## 🐛 Troubleshooting
| Problem | Solution |
|---------|----------|
| "No tests found" | Use `-tags=integration` for integration tests |
| "Connection refused" | Run `make docker-up` to start PostgreSQL |
| "Permission denied" | Run `chmod +x scripts/run-integration-tests.sh` |
| Tests fail randomly | Use `-race` flag to detect race conditions |
## 📚 Full Documentation
- **Complete Guide**: [README_TESTS.md](./README_TESTS.md)
- **Integration Details**: [INTEGRATION_TESTS.md](./INTEGRATION_TESTS.md)
- **All Commands**: `make help`
## 🎯 What Gets Tested?
### pkg/resolvespec
- ✅ Context operations
- ✅ Hook system
- ✅ CRUD operations (Create, Read, Update, Delete)
- ✅ Filtering and sorting
- ✅ Relationship preloading
- ✅ Metadata generation
### pkg/restheadspec
- ✅ Header-based API operations
- ✅ Query parameter parsing
- ✅ Pagination (limit/offset)
- ✅ Column selection
- ✅ CORS handling
- ✅ Sorting by columns
## 🚀 CI/CD
GitHub Actions workflow is ready at `.github/workflows/tests.yml`
Tests run automatically on:
- Push to main/develop branches
- Pull requests
## 💡 Tips
1. **Run unit tests frequently** - They're fast (< 1 second)
2. **Run integration tests before commits** - Catches DB issues
3. **Use `make test-integration-docker`** - Handles everything automatically
4. **Check coverage reports** - Identify untested code
5. **Use `-v` flag** - See detailed test output
## 🎓 Next Steps
1. Run unit tests: `make test-unit`
2. Try integration tests: `./scripts/run-integration-tests.sh`
3. Generate coverage: `make coverage`
4. Read full guide: `README_TESTS.md`
---
**Need Help?** Check [README_TESTS.md](./README_TESTS.md) for detailed instructions.

351
tests/README_TESTS.md Normal file
View File

@ -0,0 +1,351 @@
# Testing Guide
This document provides a comprehensive guide to running tests for the ResolveSpec project.
## Table of Contents
- [Quick Start](#quick-start)
- [Unit Tests](#unit-tests)
- [Integration Tests](#integration-tests)
- [Test Coverage](#test-coverage)
- [CI/CD](#cicd)
## Quick Start
### Run All Unit Tests
```bash
# Simple
go test ./pkg/resolvespec ./pkg/restheadspec -v
# With coverage
make test-unit
```
### Run Integration Tests (Automated with Docker)
```bash
# Easiest way - handles Docker automatically
./scripts/run-integration-tests.sh
# Or with make
make test-integration-docker
# Run specific package
./scripts/run-integration-tests.sh resolvespec
./scripts/run-integration-tests.sh restheadspec
```
### Run All Tests
```bash
make test
```
## Unit Tests
Unit tests are located alongside the source files with `_test.go` suffix and **do not** require a database.
### Test Structure
```
pkg/
├── resolvespec/
│ ├── context.go
│ ├── context_test.go # Unit tests
│ ├── handler.go
│ ├── handler_test.go # Unit tests
│ ├── hooks.go
│ ├── hooks_test.go # Unit tests
│ └── integration_test.go # Integration tests
└── restheadspec/
├── context.go
├── context_test.go # Unit tests
└── integration_test.go # Integration tests
```
### Coverage Report
#### Current Coverage
- **pkg/resolvespec**: 11.2% (improved from 0%)
- **pkg/restheadspec**: 12.5% (improved from 10.5%)
#### What's Tested
##### pkg/resolvespec
- ✅ Context operations (WithSchema, GetEntity, etc.)
- ✅ Hook registry (register, execute, clear)
- ✅ Handler initialization
- ✅ Utility functions (parseModelName, buildRoutePath, toSnakeCase)
- ✅ Column type detection
- ✅ Table name parsing
##### pkg/restheadspec
- ✅ Context operations including options
- ✅ Hook system
- ✅ Header parsing and decoding
- ✅ Query parameter parsing
- ✅ Nested relation detection
- ✅ Row number operations
### Running Specific Tests
```bash
# Run specific test function
go test ./pkg/resolvespec -run TestHookRegistry -v
# Run tests matching pattern
go test ./pkg/resolvespec -run "TestHook.*" -v
# Run with coverage
go test ./pkg/resolvespec -cover
# Generate HTML coverage report
go test ./pkg/resolvespec -coverprofile=coverage.out
go tool cover -html=coverage.out
```
## Integration Tests
Integration tests require a PostgreSQL database and use the `// +build integration` tag.
### Prerequisites
**Option 1: Docker (Recommended)**
- Docker and Docker Compose installed
**Option 2: Manual PostgreSQL**
- PostgreSQL 12+ installed and running
- Create test databases manually (see below)
### Setup with Docker
1. **Start PostgreSQL**:
```bash
make docker-up
# or
docker-compose up -d postgres-test
```
2. **Run Tests**:
```bash
# Automated (recommended)
./scripts/run-integration-tests.sh
# Manual
go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v
```
3. **Stop PostgreSQL**:
```bash
make docker-down
# or
docker-compose down
```
### Setup without Docker
1. **Create Databases**:
```sql
CREATE DATABASE resolvespec_test;
CREATE DATABASE restheadspec_test;
```
2. **Set Environment Variable**:
```bash
export TEST_DATABASE_URL="host=localhost user=postgres password=yourpass dbname=resolvespec_test port=5432 sslmode=disable"
```
3. **Run Tests**:
```bash
go test -tags=integration ./pkg/resolvespec -v
# For restheadspec, update dbname in TEST_DATABASE_URL
export TEST_DATABASE_URL="host=localhost user=postgres password=yourpass dbname=restheadspec_test port=5432 sslmode=disable"
go test -tags=integration ./pkg/restheadspec -v
```
### Integration Test Coverage
#### pkg/resolvespec (7 tests)
- ✅ Create operation
- ✅ Read operation with pagination
- ✅ Read with filters (age > 25)
- ✅ Update operation
- ✅ Delete operation
- ✅ Metadata retrieval
- ✅ Read with relationship preloading
#### pkg/restheadspec (10 tests)
- ✅ GET all records
- ✅ GET with header-based filters
- ✅ GET with pagination (limit/offset)
- ✅ GET with sorting
- ✅ GET with column selection
- ✅ GET with relationship preloading
- ✅ Metadata endpoint
- ✅ OPTIONS/CORS handling
- ✅ Query params override headers
- ✅ GET single record by ID
## Test Coverage
### Generate Coverage Reports
```bash
# Unit test coverage
make coverage
# Integration test coverage
make coverage-integration
# Both
make coverage && make coverage-integration
```
Coverage reports are generated as HTML files:
- `coverage.html` - Unit tests
- `coverage-integration.html` - Integration tests
### View Coverage
```bash
# Open in browser
open coverage.html # macOS
xdg-open coverage.html # Linux
start coverage.html # Windows
```
## Makefile Commands
```bash
make help # Show all available commands
make test-unit # Run unit tests
make test-integration # Run integration tests (requires PostgreSQL)
make test # Run all tests
make docker-up # Start PostgreSQL
make docker-down # Stop PostgreSQL
make test-integration-docker # Full automated integration test
make clean # Clean up Docker volumes
make coverage # Generate unit test coverage
make coverage-integration # Generate integration test coverage
```
## CI/CD
### GitHub Actions Example
See `INTEGRATION_TESTS.md` for a complete GitHub Actions workflow example.
Key points:
- Use PostgreSQL service container
- Run unit tests first (faster)
- Run integration tests separately
- Generate coverage reports
- Upload coverage to codecov/coveralls
### GitLab CI Example
```yaml
stages:
- test
unit-tests:
stage: test
script:
- go test ./pkg/resolvespec ./pkg/restheadspec -v -cover
integration-tests:
stage: test
services:
- postgres:15
variables:
POSTGRES_DB: resolvespec_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
TEST_DATABASE_URL: "host=postgres user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
script:
- go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v
```
## Troubleshooting
### Tests Won't Run
**Problem**: `go test` finds no tests
**Solution**: Make sure you're using the `-tags=integration` flag for integration tests
```bash
# Wrong (for integration tests)
go test ./pkg/resolvespec -v
# Correct
go test -tags=integration ./pkg/resolvespec -v
```
### Database Connection Failed
**Problem**: "connection refused" or "database does not exist"
**Solutions**:
1. Check PostgreSQL is running: `docker-compose ps`
2. Verify databases exist: `docker-compose exec postgres-test psql -U postgres -l`
3. Check environment variable: `echo $TEST_DATABASE_URL`
4. Recreate databases: `make clean && make docker-up`
### Permission Denied on Script
**Problem**: `./scripts/run-integration-tests.sh: Permission denied`
**Solution**:
```bash
chmod +x scripts/run-integration-tests.sh
```
### Tests Pass Locally but Fail in CI
**Possible causes**:
1. Different PostgreSQL version
2. Missing environment variables
3. Timezone differences
4. Race conditions (use `-race` flag to detect)
```bash
go test -race -tags=integration ./pkg/resolvespec -v
```
## Best Practices
1. **Run unit tests frequently** - They're fast and catch most issues
2. **Run integration tests before commits** - Ensures DB operations work
3. **Keep tests independent** - Each test should clean up after itself
4. **Use descriptive test names** - `TestIntegration_GetUsersWithFilters` vs `TestGet`
5. **Test error cases** - Not just the happy path
6. **Mock external dependencies** - Use interfaces for testability
7. **Maintain test data** - Keep test fixtures small and focused
## Test Data
Integration tests use these models:
- **TestUser**: id, name, email, age, active, posts[]
- **TestPost**: id, user_id, title, content, published, comments[]
- **TestComment**: id, post_id, content
Sample test data:
- 3 users (John Doe, Jane Smith, Bob Johnson)
- 3 posts (2 by John, 1 by Jane)
- 3 comments (2 on first post, 1 on second)
## Performance
- **Unit tests**: ~0.003s per package
- **Integration tests**: ~0.5-2s per package (depends on database)
- **Total**: <10 seconds for all tests
## Additional Resources
- [Go Testing Documentation](https://golang.org/pkg/testing/)
- [Table Driven Tests](https://github.com/golang/go/wiki/TableDrivenTests)
- [GORM Testing](https://gorm.io/docs/testing.html)
- [Integration Tests Guide](./INTEGRATION_TESTS.md)