package auth
import (
"strings"
"github.com/sven-seyfert/apiprobe/internal/loader"
"github.com/sven-seyfert/apiprobe/internal/logger"
"github.com/sven-seyfert/apiprobe/internal/util"
)
// RepaceAuthTokenPlaceholderInRequestHeader replaces the <auth-token> placeholder
// in request headers with the corresponding token from the token store, if available.
// Returns nothing.
func RepaceAuthTokenPlaceholderInRequestHeader(req *loader.APIRequest, tokenStore *TokenStore) {
const headerReplacementIndicator = "<auth-token>"
lookupID := req.PreRequestID
for idx, header := range req.Request.Headers {
if !strings.Contains(header, headerReplacementIndicator) {
continue
}
if token, found := tokenStore.Get(lookupID); found {
lastTokenChars := token[util.Max(0, len(token)-12):] //nolint:mnd
logger.Debugf(`Token "...%s" found for auth request "%s".`, lastTokenChars, lookupID)
req.Request.Headers[idx] = strings.ReplaceAll(header, headerReplacementIndicator, token)
break
}
logger.Warnf(`No token found for auth request "%s".`, lookupID)
}
}
// AddAuthTokenToTokenStore attempts to add the token to the provided token store
// using the request ID as the key. Returns nothing.
func AddAuthTokenToTokenStore(result []byte, tokenStore *TokenStore, req *loader.APIRequest) {
token := util.TrimQuotes(string(result))
lastTokenChars := token[util.Max(0, len(token)-12):] //nolint:mnd
if added := tokenStore.Add(req.ID, token); added {
logger.Debugf(`Token "...%s" for auth request "%s" added to token store.`, lastTokenChars, req.ID)
} else {
logger.Warnf(`Token "...%s" for auth request "%s" already exists in token store.`, lastTokenChars, req.ID)
}
}
package auth
// TokenStore maintains a map of request IDs to API tokens.
// Each key is a 10 character hex hash, and each value
// is the corresponding token.
type TokenStore struct {
data map[string]string
}
// NewTokenStore initializes and returns a new TokenStore.
func NewTokenStore() *TokenStore {
return &TokenStore{
data: make(map[string]string),
}
}
// Add inserts a token for the given id if it does not already exist.
// Returns true if the token was added, false if the id already exists.
func (t *TokenStore) Add(id, token string) bool {
if _, exists := t.data[id]; exists {
return false
}
t.data[id] = token
return true
}
// Get retrieves the token for the given id. Returns the token and
// true if found, or "" and false otherwise.
func (t *TokenStore) Get(id string) (string, bool) {
if t, found := t.data[id]; found {
return t, true
}
return "", false
}
package config
import (
"encoding/json"
"os"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
const Version = "APIProbe 📡 v0.18.0 - 2025-11-07"
type Heartbeat struct {
IntervalInHours int `json:"intervalInHours"`
LastHeartbeatTime string `json:"lastHeartbeatTime"`
}
type Notification struct {
WebEx *struct {
Active bool `json:"active"`
WebhookURL string `json:"webhookUrl"`
Space string `json:"space"`
} `json:"webEx"`
}
type Config struct {
DebugMode bool `json:"debugMode"`
Heartbeat Heartbeat `json:"heartbeat"`
Notification Notification `json:"notification"`
}
// Load opens the JSON configuration file, decodes its contents into
// a Config struct and returns the loaded configuration or an error.
func Load(filePath string) (*Config, error) {
file, err := os.Open(filePath)
if err != nil {
logger.Errorf(`Failure opening config file "%s". Error: %v`, filePath, err)
return nil, err
}
defer file.Close()
decoder := json.NewDecoder(file)
var cfg Config
if err = decoder.Decode(&cfg); err != nil {
logger.Errorf(`Failure parsing config file "%s". Error: %v`, filePath, err)
return nil, err
}
return &cfg, nil
}
package crypto
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"math/big"
"strings"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
// Obfuscate encodes a string with Base64 and random characters
// to produce a token.
func Obfuscate(data string) string {
core := base64.StdEncoding.EncodeToString([]byte(data))
core = strings.ReplaceAll(core, "=", "-")
return fmt.Sprintf("ey%s.%s%s%s.%s", chars(9), chars(2), core, chars(6), chars(24)) //nolint:mnd
}
// Deobfuscate decodes a token back into its original plaintext.
func Deobfuscate(data string) string {
if data == "" {
return ""
}
core := data[14 : len(data)-31]
core = strings.ReplaceAll(core, "-", "=")
byteString, err := base64.StdEncoding.DecodeString(core)
if err != nil {
logger.Warnf("Decryption failed: %s", err)
return ""
}
return string(byteString)
}
// chars returns a random alphanumeric string of the specified length.
func chars(length int) string {
const alphaNum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
byteString := make([]byte, length)
for idx := range byteString {
nBig, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphaNum))))
byteString[idx] = alphaNum[nBig.Int64()]
}
return string(byteString)
}
// HexHash returns a cryptographically secure random hex string of length 10.
// It reads the needed random bytes, encodes them, and truncates to 10 chars.
func HexHash() (string, error) {
const charCount = 10
byteLen := (charCount + 1) / 2 //nolint:mnd
buf := make([]byte, byteLen)
if _, err := rand.Read(buf); err != nil {
return "", err
}
hexStr := hex.EncodeToString(buf)
return hexStr[:charCount], nil
}
package crypto
import (
"fmt"
"regexp"
"strings"
"zombiezen.com/go/sqlite"
"github.com/sven-seyfert/apiprobe/internal/db"
"github.com/sven-seyfert/apiprobe/internal/loader"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
// HandleSecrets iterates over each APIRequest in filteredRequests, finds all
// placeholders '<secret-<hash>>' in PostBody, BasicAuth, Params, Headers and TestCases,
// retrieves the real secret from the database, deobfuscates it, and replaces the
// placeholder. Returns an error immediately if any DB lookup fails.
func HandleSecrets(filteredRequests []*loader.APIRequest, conn *sqlite.Conn) ([]*loader.APIRequest, error) {
for _, req := range filteredRequests {
newBody, err := replaceSecretInString(req.Request.PostBody, conn)
if err != nil {
return nil, err
}
req.Request.PostBody = newBody
newAuth, err := replaceSecretInString(req.Request.BasicAuth, conn)
if err != nil {
return nil, err
}
req.Request.BasicAuth = newAuth
if err = replaceSecretInSlice(req.Request.Params, conn); err != nil {
return nil, err
}
if err = replaceSecretInSlice(req.Request.Headers, conn); err != nil {
return nil, err
}
if err = replaceSecretInTestCases(req.TestCases, conn); err != nil {
return nil, err
}
}
return filteredRequests, nil
}
// replaceSecretInString searches a single string for '<secret-<hash>>'
// patterns. For each found hash, it retrieves the secret from the database,
// deobfuscates it, and replaces the placeholder in the string.
// Returns an error if DB lookup fails.
func replaceSecretInString(str string, conn *sqlite.Conn) (string, error) {
const secretPrefix = "<secret-"
if !strings.Contains(str, secretPrefix) {
return str, nil
}
secretHash := ExtractSecretHash(str)
if secretHash == "" {
logger.Warnf("No valid secret hash found in string: %s", str)
return str, nil
}
secret, err := db.SelectHash(conn, secretHash)
if err != nil {
logger.Debugf(`Failed to retrieve secret for hash "%s": %v`, secretHash, err)
return "", err
}
if secret != "" {
from := fmt.Sprintf("%s%s>", secretPrefix, secretHash)
to := Deobfuscate(secret)
return strings.ReplaceAll(str, from, to), nil
}
logger.Warnf(`Secret value "%s" not found`, secretHash)
return str, nil
}
// replaceSecretInSlice iterates over a slice of strings, calls replaceSecretInString
// on each element, and updates the slice in-place.
// Returns the first error encountered, if any.
func replaceSecretInSlice(reqSlice []string, conn *sqlite.Conn) error {
for idx, val := range reqSlice {
newVal, err := replaceSecretInString(val, conn)
if err != nil {
return err
}
reqSlice[idx] = newVal
}
return nil
}
// replaceSecretInTestCases iterates over all test cases and replaces secrets
// in the ParamsData and PostBodyData fields in-place.
// Returns the first error encountered, if any.
func replaceSecretInTestCases(testCases []loader.TestCases, conn *sqlite.Conn) error {
for idx := range testCases {
testCase := &testCases[idx]
var err error
if testCase.ParamsData != "" {
testCase.ParamsData, err = replaceSecretInString(testCase.ParamsData, conn)
if err != nil {
logger.Errorf(`Error replacing secret in ParamsData of test "%q".`, testCase.Name)
return err
}
}
if testCase.PostBodyData != "" {
testCase.PostBodyData, err = replaceSecretInString(testCase.PostBodyData, conn)
if err != nil {
logger.Errorf(`Error replacing secret in PostBodyData of test "%q".`, testCase.Name)
return err
}
}
}
return nil
}
// ExtractSecretHash uses a precompiled regex to extract the hash from
// a '<secret-<hash>>' placeholder. Returns the hash without angle brackets
// or prefix or an empty string if no match is found.
func ExtractSecretHash(input string) string {
pattern := regexp.MustCompile(`<secret-([^>]+)>`)
matches := pattern.FindStringSubmatch(input)
if len(matches) < 2 { //nolint:mnd
logger.Warnf(`"No secret hash found: "%s"`, input)
return ""
}
return matches[1]
}
package db
import (
"encoding/csv"
"fmt"
"io"
"os"
"strings"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitex"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
// Init opens or creates the SQLite database file at './db/store.db',
// ensures that the 'secrets' table exists and returns the active
// connection to the caller.
func Init() (*sqlite.Conn, error) {
// Create database.
conn, err := sqlite.OpenConn("./db/store.db", sqlite.OpenReadWrite, sqlite.OpenCreate)
if err != nil {
logger.Errorf("Failed to open database. Error: %v", err)
return nil, err
}
// Create table if it does not exist.
createTableSQL := `
CREATE TABLE IF NOT EXISTS secrets (
hash TEXT PRIMARY KEY,
secret TEXT NOT NULL
);`
err = sqlitex.ExecuteTransient(conn, createTableSQL, nil)
if err != nil {
logger.Errorf("Failed to create database. Error: %v", err)
return nil, err
}
return conn, nil
}
// InsertSeedData checks if the 'secrets' table is empty;
// if so, reads './db/seed.csv', constructs a bulk-insert SQL statement
// and populates the table. Returns an error if any operation fails.
func InsertSeedData(conn *sqlite.Conn) error {
// Check if the table is empty.
count, err := GetTableEntryCount(conn)
if err != nil {
return err
}
if count > 0 {
return nil
}
// Insert data (bulk insert).
SQLValues, err := readSeedData()
if err != nil {
return err
}
bulkInsertSQL := "INSERT INTO secrets(hash, secret) VALUES" + SQLValues
err = sqlitex.ExecuteTransient(conn, bulkInsertSQL, nil)
if err != nil {
logger.Errorf("Failed to insert data. Error: %v", err)
return err
}
return nil
}
// GetTableEntryCount returns the total number of rows in the 'secrets'
// table by executing 'SELECT COUNT(*)'.
func GetTableEntryCount(conn *sqlite.Conn) (int, error) {
var count int
countSQL := "SELECT COUNT(*) FROM secrets"
err := sqlitex.ExecuteTransient(conn, countSQL, &sqlitex.ExecOptions{
Args: nil,
Named: nil,
ResultFunc: func(stmt *sqlite.Stmt) error {
count = stmt.ColumnInt(0)
return nil
},
})
if err != nil {
logger.Errorf("Failed to query table count. Error: %v", err)
return 0, err
}
return count, nil
}
// readSeedData reads './db/seed.csv', each line containing 'hash,secret'
// and returns a string suitable for a SQL VALUES clause for a bulk insert.
func readSeedData() (string, error) {
file, err := os.Open("./db/seed.csv")
if err != nil {
logger.Errorf("Failure opening file. Error: %v", err)
return "", err
}
defer file.Close()
reader := csv.NewReader(file)
var values []string
for {
record, readErr := reader.Read()
if readErr == io.EOF {
break
}
if readErr != nil {
logger.Errorf("Failure reading file. Error: %v", readErr)
return "", readErr
}
hash := record[0]
secret := record[1]
values = append(values, fmt.Sprintf("('%s', '%s')", hash, secret))
}
joinedValues := strings.Join(values, ",\n ")
bulkInsertSQL := fmt.Sprintf("\n %s;", joinedValues)
return bulkInsertSQL, nil
}
// InsertSecret stores a new (hash, secret) pair into the 'secrets' table
// using parameterized SQL to avoid injection. Returns an error if insertion fails.
func InsertSecret(conn *sqlite.Conn, hash string, secret string) error {
stmt, _, err := conn.PrepareTransient("INSERT INTO secrets(hash, secret) VALUES (?, ?)")
if err != nil {
logger.Errorf("Failed to prepare insert statement. Error: %v", err)
return err
}
defer func() {
if err = stmt.Finalize(); err != nil {
logger.Errorf("Failed to finalize statement. Error: %v", err)
}
}()
stmt.BindText(1, hash)
stmt.BindText(2, secret) //nolint:mnd
if _, err = stmt.Step(); err != nil {
logger.Errorf("Failed to execute insert statement. Error: %v", err)
return err
}
return nil
}
// SelectHash queries the 'secrets' table for the given hash
// and returns its stored secret. Returns an empty string if no row is found
// or an error on failure.
func SelectHash(conn *sqlite.Conn, hash string) (string, error) {
stmt, _, err := conn.PrepareTransient("SELECT secret FROM secrets WHERE hash = ?")
if err != nil {
logger.Errorf("Failed to prepare select statement. Error: %v", err)
return "", err
}
defer func() {
if err = stmt.Finalize(); err != nil {
logger.Errorf("Failed to finalize statement. Error: %v", err)
}
}()
stmt.BindText(1, hash)
hasRow, err := stmt.Step()
if err != nil {
logger.Errorf("Failed to execute select statement. Error: %v", err)
return "", err
}
if !hasRow {
return "", nil
}
secret := stmt.ColumnText(0)
return secret, nil
}
package diff
import (
"crypto/sha256"
"os"
"github.com/sven-seyfert/apiprobe/internal/fileutil"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
// HasFileContentChanged compares the SHA256 checksum of the given output
// bytes against the current contents of outputPath. If they differ,
// writes the new content to file and returns true;
// otherwise logs 'No change' and returns false.
func HasFileContentChanged(output []byte, outputPath string) (bool, error) {
err := fileutil.EnsureFileExists(outputPath)
if err != nil {
return false, err
}
newHash := sha256.Sum256(output)
var prevHash [32]byte
existing, err := os.ReadFile(outputPath)
if err != nil {
logger.Errorf(`Failed to read file "%s"`, outputPath)
return false, err
}
prevHash = sha256.Sum256(existing)
if newHash == prevHash {
logger.Infof(`No change for "%s"`, outputPath)
return false, nil
}
logger.Infof(`Detected change (diff) in "%s"`, outputPath)
if err = fileutil.WriteOutputFile(outputPath, output); err != nil {
return true, err
}
return true, nil
}
package exec
import "strings"
type token struct {
option string
value string
hasValue bool
}
// buildCurlFormat formats a given input string into a multi-line,
// indented curl command. Returns the formatted curl command as a
// string or an empty string if input is invalid.
func buildCurlFormat(input string) string {
parts := strings.Fields(input)
if len(parts) == 0 {
return ""
}
const (
executable = "curl"
indent = " "
backslash = " \\"
)
tokens := parseTokens(parts[1:])
isFirstOption := true
var lines []string
for _, tok := range tokens {
if !tok.hasValue {
if isFirstOption {
lines = append(lines, executable+" "+tok.option+backslash)
isFirstOption = false
} else {
lines = append(lines, indent+tok.option+backslash)
}
continue
}
formattedValue := quoteValue(tok.option, tok.value)
if isFirstOption {
lines = append(lines, executable+" "+tok.option+" "+formattedValue+backslash)
isFirstOption = false
} else {
lines = append(lines, indent+tok.option+" "+formattedValue+backslash)
}
}
if len(lines) == 0 {
return executable
}
result := strings.Join(lines, "\n")
result = strings.TrimRight(result, backslash)
return result
}
// parseTokens parses command line parts into tokens with options and values.
// Returns a slice of token structs.
func parseTokens(parts []string) []token {
var tokens []token
valueFlags := map[string]struct{}{
"--request": {},
"--connect-timeout": {},
"--max-time": {},
"--url": {},
"--write-out": {},
"--data": {},
"--user": {},
"--header": {},
}
for idx := 0; idx < len(parts); idx++ {
part := parts[idx]
if !strings.HasPrefix(part, "-") {
continue
}
if _, ok := valueFlags[part]; !ok {
tokens = append(tokens, token{option: part, value: "", hasValue: false})
continue
}
value := ""
nextIndex := idx + 1
for nextIndex < len(parts) && !strings.HasPrefix(parts[nextIndex], "-") {
if value != "" {
value += " "
}
value += parts[nextIndex]
nextIndex++
}
tokens = append(tokens, token{option: part, value: value, hasValue: true})
idx = nextIndex - 1
}
return tokens
}
// quoteValue adds appropriate quoting to a flag value based on the flag type.
// Returns the quoted value as a string.
func quoteValue(flag, value string) string {
switch flag {
case "--request", "--connect-timeout", "--max-time":
return value
default:
return "'" + escapeSingleQuotes(value) + "'"
}
}
// escapeSingleQuotes escapes single quotes in a string for shell safety.
// Returns the escaped string.
func escapeSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", "'\\''")
}
package exec
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"time"
"github.com/sven-seyfert/apiprobe/internal/loader"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
// runCurl executes an external 'curl' command with specified timeouts
// and write-out flags, captures its stdout, splits the HTTP status code
// and returns the response body if the status code is 2xx;
// otherwise, returns an error.
func runCurl(ctx context.Context, req *loader.APIRequest, debugMode bool) ([]byte, string, string, error) {
cmdArgs := req.CurlCmdArguments()
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, "./lib/curl.exe", cmdArgs...)
if debugMode {
fmt.Printf("\n%s\n\n", buildCurlFormat(cmd.String())) //nolint:forbidigo
}
cmd.Stdout = &stdout
cmd.Stderr = &stderr
logger.Debugf(`Executing endpoint request "%s"`, req.Request.Endpoint)
logger.Infof(`Description: "%s"`, req.Request.Description)
start := time.Now()
err := cmd.Run()
duration := time.Since(start)
rawOutput := stdout.Bytes()
if err != nil {
logger.Errorf("Curl execution failed. Error: %v", err)
logger.Errorf("StdOut response: %s", stdout.String())
logger.Errorf("StdErr response: %s", stderr.String())
return nil, "", stderr.String(), fmt.Errorf("curl error: %w", err)
}
body, statusCode, err := extractStatusCode(rawOutput)
if err != nil {
return nil, "", stdout.String(), err
}
logger.Debugf("Status: %s, Duration: %dms", statusCode, duration.Milliseconds())
if !strings.HasPrefix(statusCode, "2") {
logger.Warnf("Non-2xx status code received: status %s", statusCode)
logger.Warnf("Curl stdout response: %s", body)
return nil, statusCode, string(body), fmt.Errorf("status %s", statusCode)
}
return body, statusCode, "", nil
}
// extractStatusCode splits the raw output from curl (where the last
// three bytes encode the HTTP status code) into the response body
// and the status code string.
func extractStatusCode(output []byte) ([]byte, string, error) {
const HTTPCodeLength = 3
if len(output) < HTTPCodeLength {
logger.Warnf("Output too short to contain status code: only %d bytes", len(output))
return nil, "", fmt.Errorf("only %d bytes", len(output))
}
body := output[:len(output)-HTTPCodeLength]
statusCode := string(output[len(output)-HTTPCodeLength:])
return body, statusCode, nil
}
package exec
import (
"context"
"strings"
"github.com/sven-seyfert/apiprobe/internal/auth"
"github.com/sven-seyfert/apiprobe/internal/diff"
"github.com/sven-seyfert/apiprobe/internal/fileutil"
"github.com/sven-seyfert/apiprobe/internal/loader"
"github.com/sven-seyfert/apiprobe/internal/logger"
"github.com/sven-seyfert/apiprobe/internal/report"
"github.com/sven-seyfert/apiprobe/internal/util"
)
// ProcessFirstRequest executes the APIRequest (including optional test cases),
// compares the response against existing output, and triggers the webhook
// if differences are detected.
func ProcessFirstRequest(
ctx context.Context,
idx int,
req *loader.APIRequest,
testCaseIndex *int,
res *report.Result,
rep *report.Report,
tokenStore *auth.TokenStore,
debugMode bool,
) {
if testCaseIndex != nil {
logger.NewLine()
logger.Debugf("Run: %d, Test case: %d", idx, *testCaseIndex+1)
}
const noTestCaseIndicator = -1
outputFile := fileutil.BuildOutputFilePath(req, testCaseIndex)
response, statusCode, errorResponse, err := executeRequest(ctx, req, debugMode)
if err != nil {
logger.Errorf(`Failed endpoint request "%s": %v`, req.Request.Endpoint, err)
res.IncreaseRequestErrorCount()
if testCaseIndex != nil {
rep.AddReportData(req, statusCode, errorResponse, outputFile, *testCaseIndex)
} else {
rep.AddReportData(req, statusCode, errorResponse, outputFile, noTestCaseIndicator)
}
return
}
result, err := formatResponse(ctx, req, response)
if err != nil {
logger.Errorf("Failed processing JSON query by JQ. Error: %v", err)
res.IncreaseFormatErrorCount()
if testCaseIndex != nil {
rep.AddReportData(req, statusCode, errorResponse, outputFile, *testCaseIndex)
} else {
rep.AddReportData(req, statusCode, errorResponse, outputFile, noTestCaseIndicator)
}
return
}
if req.IsAuthRequest {
auth.AddAuthTokenToTokenStore(result, tokenStore, req)
logger.Debugf("No output file will be written (unnecessary), because generic token result.")
return
}
hasChanged, err := diff.HasFileContentChanged(result, outputFile)
if err != nil {
logger.Errorf("%v", err)
return
}
if !hasChanged {
return
}
res.IncreaseChangedFilesCount()
if testCaseIndex != nil {
rep.AddReportData(req, statusCode, errorResponse, outputFile, *testCaseIndex)
} else {
rep.AddReportData(req, statusCode, errorResponse, outputFile, noTestCaseIndicator)
}
}
// ProcessTestCasesRequests executes all test case variations for a given
// API request. Returns nothing.
func ProcessTestCasesRequests(
ctx context.Context,
req *loader.APIRequest,
idx int,
res *report.Result,
rep *report.Report,
tokenStore *auth.TokenStore,
debugMode bool,
) {
for testCaseIndex, testCase := range req.TestCases {
if testCase.ParamsData == "" && testCase.PostBodyData == "" {
continue
}
modifiedReq := *req
if testCase.ParamsData != "" {
modifiedReq.Request.Params = util.ReplaceQueryParam(req.Request.Params, testCase.ParamsData)
}
if testCase.PostBodyData != "" {
modifiedReq.Request.PostBody = testCase.PostBodyData
}
ProcessFirstRequest(ctx, idx+1, &modifiedReq, &testCaseIndex, res, rep, tokenStore, debugMode)
logger.Infof("Test case: %s", testCase.Name)
}
}
// executeRequest wraps runCurl to perform the HTTP request defined by APIRequest
// and returns the raw response body, status code and potential error information.
func executeRequest(ctx context.Context, req *loader.APIRequest, debugMode bool) ([]byte, string, string, error) {
curlOutput, statusCode, errorResponse, err := runCurl(ctx, req, debugMode)
if err != nil {
return nil, statusCode, errorResponse, err
}
return curlOutput, statusCode, errorResponse, nil
}
// formatResponse formats the curl output using jq
// and returns the filtered result.
func formatResponse(ctx context.Context, req *loader.APIRequest, response []byte) ([]byte, error) {
// If response is not JSON ("content-type: application/json"),
// it's plain text and therefore there is no need for jq formatting.
if !strings.HasPrefix(string(response), "{") && !strings.HasPrefix(string(response), "[") {
return response, nil
}
jqOutput, err := GoJQ(ctx, req.JqCommand, response)
if err != nil {
return nil, err
}
return jqOutput, nil
}
package exec
import (
"context"
"encoding/json"
"fmt"
"github.com/itchyny/gojq"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
// GoJQ executes the jq query given by jqCommand against inputJSON.
// Returns the encoded JSON ([]byte) of the query result or an error.
func GoJQ(ctx context.Context, jqCommand string, inputJSON []byte) ([]byte, error) {
const defaultJQPrettifyFilter = "."
if jqCommand == "" {
jqCommand = defaultJQPrettifyFilter
}
var input any
if err := json.Unmarshal(inputJSON, &input); err != nil {
logger.Errorf("Failed to unmarshal input (invalid input json). Error: %v", err)
return nil, err
}
code, err := compileQuery(jqCommand)
if err != nil {
return nil, err
}
results, err := runQuery(ctx, code, input)
if err != nil {
return nil, err
}
return encodeResults(results)
}
// compileQuery parses and compiles the provided jqCommand into *gojq.Code.
// Returns the compiled code or an error if parsing/compilation fails.
func compileQuery(jqCommand string) (*gojq.Code, error) {
query, err := gojq.Parse(jqCommand)
if err != nil {
logger.Errorf("Failed to parse jqCommand. Error: %v", err)
return nil, err
}
code, err := gojq.Compile(query)
if err != nil {
logger.Errorf("Failed to compile jq. Error: %v", err)
return nil, err
}
return code, nil
}
// runQuery executes the compiled gojq code with the provided context and input,
// collects all produced values and handles gojq.HaltError specially.
// Returns a slice of results ([]any) or an error.
func runQuery(ctx context.Context, code *gojq.Code, input any) ([]any, error) {
iter := code.RunWithContext(ctx, input)
results := []any{}
for {
nextVal, isOk := iter.Next()
if !isOk {
break
}
// If the iterator produced a non-error value,
// append and continue early.
errVal, isErr := nextVal.(error)
if !isErr {
results = append(results, nextVal)
continue
}
// Handle error values
if halt, ok := errVal.(*gojq.HaltError); ok { //nolint:errorlint
if halt.Value() == nil {
break
}
// If HaltError carries a value,
// append it and continue.
if ve, okay := errVal.(interface{ Value() any }); okay {
results = append(results, ve.Value())
continue
}
logger.Errorf("Failed to run query. JQ halt error with non-nil value (type=%T).", errVal)
return nil, errVal
}
logger.Errorf("Failed to run query. JQ runtime error: %s (type=%T)", safeErrorString(errVal), errVal)
return nil, errVal
}
return results, nil
}
// encodeResults marshals results to indented JSON. If results contains exactly
// one element, that element is marshaled directly; otherwise the whole slice
// is marshaled. Returns the JSON bytes ([]byte) or an error.
func encodeResults(results []any) ([]byte, error) {
var out any
if len(results) == 1 {
out = results[0]
} else {
out = results
}
enc, err := json.MarshalIndent(out, "", " ")
if err != nil {
logger.Errorf("Failed to marshal indent. Marshal output: %v", err)
return nil, err
}
return enc, nil
}
// safeErrorString returns err.Error(), but recovers and returns a fallback
// string if calling Error() panics.
func safeErrorString(err error) string {
var errMsg string
func() {
defer func() {
if rec := recover(); rec != nil {
errMsg = fmt.Sprintf("error.Error() panicked: %v (error type=%T)", rec, err)
}
}()
errMsg = err.Error()
}()
return errMsg
}
package fileutil
import (
"os"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
// EnsureFileExists ensures that the target file and its directory exist,
// creating them if necessary.
func EnsureFileExists(outputPath string) error {
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
if err = createOutputDir(outputPath); err != nil {
return err
}
if err = WriteOutputFile(outputPath, nil); err != nil {
return err
}
}
return nil
}
// WriteOutputFile writes byte content to the specified file
// with defined permissions.
func WriteOutputFile(outputPath string, output []byte) error {
const permissions = 0o644
if err := os.WriteFile(outputPath, output, permissions); err != nil {
logger.Errorf(`Failed to write file "%s". Error: %v`, outputPath, err)
return err
}
return nil
}
package fileutil
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sven-seyfert/apiprobe/internal/loader"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
// BuildOutputFilePath computes the output file path for a
// given APIRequest and optional test case index, by inserting
// '-test-case-XX' into the JSON file name and nesting under
// './data/output'.
func BuildOutputFilePath(req *loader.APIRequest, testCaseIndex *int) string {
outputDir := "./data/output"
fileExt := filepath.Ext(req.JSONFilePath)
file := req.JSONFilePath
if testCaseIndex != nil {
file = strings.Replace(file, fileExt, fmt.Sprintf("-test-case-%02d%s", *testCaseIndex+1, fileExt), 1)
} else {
file = strings.Replace(file, fileExt, fmt.Sprintf("-test-case-%02d%s", 0, fileExt), 1)
}
return filepath.Join(outputDir, file)
}
// createOutputDir ensures that the parent directory for the given
// output path exists. If necessary, it creates all missing directories.
func createOutputDir(outputPath string) error {
const permissions = 0o755
if err := os.MkdirAll(filepath.Dir(outputPath), permissions); err != nil {
logger.Errorf(`Failed to create output directory "%s". Error: %v"`, outputPath, err)
return err
}
return nil
}
package flags
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"zombiezen.com/go/sqlite"
"github.com/sven-seyfert/apiprobe/internal/config"
"github.com/sven-seyfert/apiprobe/internal/crypto"
"github.com/sven-seyfert/apiprobe/internal/db"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
type CLIFlags struct {
Name *string
ID *string
Tags *string
Exclude *string
NewID *bool
NewFile *bool
AddSecret *string
}
// Init defines and parses the CLI flags and returning their values.
func Init() *CLIFlags {
flag.Usage = func() { //nolint:reassign
fmt.Fprintf(os.Stderr, config.Version+"\n\n")
fmt.Fprintf(os.Stderr, "Usage:\n")
flag.PrintDefaults()
}
nameUsage := "Custom name for this test run (for this execution). Shown in the final notification to help identify the run.\n" +
"Example: --name \"Environment: PROD\"\n"
idUsage := "Specify the ten-character hex hash (id) of the request to run.\n" +
"The hash must match the JSON \"id\" value, in the JSON definition (input) files.\n" +
"In combination with the --exclude flag, exclude will be prioritized.\n" +
"Example: --id \"ff00fceb61\"\n"
tagUsage := "Specify a comma-separated list of tags to select which requests to run.\n" +
"Tags must match the JSON \"tags\" value, in the JSON definition (input) files.\n" +
"In combination with the --exclude flag, exclude will be prioritized.\n" +
"Example: --tags \"reqres, booker\"\n"
excludeUsage := "Specify a comma-separated list of IDs (hashes) to exclude from the execution.\n" +
"The IDs must match the JSON \"id\" value, in the JSON definition (input) files.\n" +
"Example: --exclude \"bb5599abcd, ff00fceb61\"\n"
newIDUsage := "Generate a new ten-character hex hash (id) for the \n" +
"JSON \"id\" value, in the JSON definition (input) file.\n" +
"Example: --new-id\n"
newFileUsage := "Generate a new JSON definition template file.\n" +
"Then enter the request values/data and done.\n" +
"Example: --new-file\n"
addSecretUsage := "Stores a secret (e.g., API request token, api-key, a bearer token or\n" +
"other request secrets) in the database and return a placeholder such as \"<secret-b29ff12b50>\".\n" +
"Use this placeholder in your JSON definition (input) file instead of the actual secret value.\n" +
"Example: --add-secret \"ThisIsMySecretText\"\n"
cliFlags := &CLIFlags{
Name: flag.String("name", "", nameUsage),
ID: flag.String("id", "", idUsage),
Tags: flag.String("tags", "", tagUsage),
Exclude: flag.String("exclude", "", excludeUsage),
NewID: flag.Bool("new-id", false, newIDUsage),
NewFile: flag.Bool("new-file", false, newFileUsage),
AddSecret: flag.String("add-secret", "", addSecretUsage),
}
flag.Parse()
return cliFlags
}
// IsNewID checks whether a new ID should be generated, and if so,
// produces a cryptographically secure hex hash and prints it and
// returns an instruction to exit the program or not.
func IsNewID(isNewID bool) (bool, error) {
complete := false
if !isNewID {
return complete, nil
}
hash, err := crypto.HexHash()
if err != nil {
logger.Errorf("Failed to generate new ID. Error: %v", err)
return complete, err
}
fmt.Printf(`Use this ID "%s" in your JSON file, key "id".`, hash) //nolint:forbidigo
complete = true
return complete, nil
}
// IsNewFile checks if a new file should be created. If true, it generates an ID,
// writes a new template JSON file, and returns true on success. Returns false
// and an error if any step fails.
func IsNewFile(isNewFile bool) (bool, error) {
complete := false
if !isNewFile {
return complete, nil
}
hash, err := crypto.HexHash()
if err != nil {
logger.Errorf("Failed to generate new ID. Error: %v", err)
return complete, err
}
if err = writeNewTemplateJSONFile(hash); err != nil {
return complete, err
}
complete = true
return complete, nil
}
// writeNewTemplateJSONFile creates a new JSON definition file (a template)
// with a given ID as content. Returns an error if directory creation
// or file writing fails.
func writeNewTemplateJSONFile(hash string) error {
content := `[
{
"id": "${ID}",
"isActive": true,
"isAuthRequest": false,
"preRequestId": "",
"request": {
"description": "...",
"method": "GET",
"url": "https://...",
"endpoint": "/...",
"basicAuth": "",
"headers": [],
"params": [],
"postBody": {},
"name": ""
},
"testCases": [
{
"name": "",
"paramsData": "",
"postBodyData": {}
}
],
"tags": [
"env-prod"
],
"jq": ""
}
]`
const (
path = "./data/input/"
file = "new-template.json"
createPermissions = 0o755
writePermissions = 0o644
)
err := os.MkdirAll(filepath.Dir(path), createPermissions)
if err != nil {
logger.Errorf(`Failed to create data/input directory "%s". Error: %v`, file, err)
return err
}
filePath := filepath.Join(path, file)
content = strings.Replace(content, "${ID}", hash, 1)
err = os.WriteFile(filePath, []byte(content), writePermissions)
if err != nil {
logger.Errorf(`Failed to write file "%s". Error: %v`, filePath, err)
return err
}
return nil
}
// IsAddSecret validates the provided secret string and, if non-empty,
// generates a cryptographically secure hex hash to serve as a placeholder
// and prints it and returns an instruction to exit the program or not.
func IsAddSecret(givenSecret string, conn *sqlite.Conn) (bool, error) {
complete := false
if givenSecret == "" {
return complete, nil
}
hash, err := crypto.HexHash()
if err != nil {
logger.Errorf("Failed to generate new ID. Error: %v", err)
return complete, err
}
DBValidSecret := crypto.Obfuscate(givenSecret)
countBefore, err := db.GetTableEntryCount(conn)
if err != nil {
return complete, err
}
if err = db.InsertSecret(conn, hash, DBValidSecret); err != nil {
return complete, err
}
countAfter, err := db.GetTableEntryCount(conn)
if err != nil {
return complete, err
}
fmt.Printf("%d ==> %d\n"+ //nolint:forbidigo
"Use this placeholder \"<secret-%s>\" in your JSON file "+
"instead of the actual secret value.", countBefore, countAfter, hash)
complete = true
return complete, nil
}
package loader
import (
"errors"
"regexp"
"strings"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
// ExcludeRequestsByID returns a filtered slice of APIRequest, excluding any
// requests whose IDs are listed in the comma-separated excludeIDs string.
func ExcludeRequestsByID(requests []*APIRequest, excludeIDs string) []*APIRequest {
if excludeIDs == "" {
return requests
}
idList := strings.Split(excludeIDs, ",")
excludeSet := make(map[string]struct{})
for _, id := range idList {
id = strings.TrimSpace(id)
if id != "" {
excludeSet[id] = struct{}{}
}
}
var filteredRequests []*APIRequest
for _, req := range requests {
if _, found := excludeSet[req.ID]; !found {
filteredRequests = append(filteredRequests, req)
}
}
return filteredRequests
}
// FilterRequests filters the given slice of APIRequest by the '--id'
// and '--tags' flags. It returns a slice of matching requests and a
// boolean flag that is true if no requests matched the filters.
func FilterRequests(requests []*APIRequest, id string, tags string) ([]*APIRequest, bool) { //nolint:varnamelen
if len(requests) == 0 {
logger.Warnf(`No requests found.`)
return requests, true
}
// Filter requests by ID.
if id != "" {
if req := filterByID(requests, id); req != nil {
return []*APIRequest{req}, false
}
logger.Warnf(`No request with id (hex hash) "%s" found.`, id)
return requests, true
}
// Or filter requests by tags.
if tags != "" {
tagsList := strings.Split(tags, ",")
wantedTags := make([]string, 0, len(tagsList))
for _, tag := range tagsList {
tag = strings.TrimSpace(tag)
if tag != "" {
wantedTags = append(wantedTags, tag)
}
}
filteredRequests := filterByTags(requests, wantedTags)
if len(filteredRequests) > 0 {
return filteredRequests, false
}
logger.Warnf(`No requests found for tags "%s".`, tags)
return requests, true
}
// Or use the fallback (return all requests).
return requests, false
}
// filterByID searches a slice of APIRequest for the given ID
// and returns the first matching object.
func filterByID(requests []*APIRequest, id string) *APIRequest {
for _, req := range requests {
if req.ID == id {
return req
}
}
return nil
}
// filterByTags returns all APIRequest objects whose tags intersect
// with the desired tag list.
func filterByTags(requests []*APIRequest, wantedTags []string) []*APIRequest {
// Build a set (map) for O|1 lookup of desired tags.
wantedSet := make(map[string]struct{}, len(wantedTags))
for _, w := range wantedTags {
wantedSet[w] = struct{}{}
}
var filteredRequests []*APIRequest
// Check each request only once.
for _, req := range requests {
for _, tag := range req.Tags {
if _, ok := wantedSet[tag]; ok {
filteredRequests = append(filteredRequests, req)
break
}
}
}
return filteredRequests
}
// MergePreRequests constructs a merged requests list in which, for each
// filtered request having a PreRequestID, the corresponding loaded request
// is prepended before the filtered requests. It returns the gathered/merged
// APIRequest list without duplicates.
func MergePreRequests(loadedRequests []*APIRequest, filteredRequests []*APIRequest) ([]*APIRequest, error) {
lookupMap := make(map[string]*APIRequest, len(loadedRequests))
for _, loadedReq := range loadedRequests {
lookupMap[loadedReq.ID] = loadedReq
}
const tenCharHexHashPattern = `^[a-fA-F0-9]{10}$`
hexPattern := regexp.MustCompile(tenCharHexHashPattern)
requestsList := make([]*APIRequest, 0, len(loadedRequests)+len(filteredRequests))
// Handle possible pre-requests by PreRequestID.
for _, filteredReq := range filteredRequests {
preID := filteredReq.PreRequestID
if preID == "" {
continue
}
if !hexPattern.MatchString(preID) {
logger.Errorf(`PreRequestID "%s" has invalid format (not the expected ten character hex hash format).`, preID)
return nil, errors.New("invalid format error")
}
prev, found := lookupMap[preID]
if !found {
logger.Errorf(`PreRequestID "%s" not found in loadedRequests.`, preID)
return nil, errors.New("not found error")
}
requestsList = append(requestsList, prev)
}
// Append all filtered requests (to be behind the pre-requests).
requestsList = append(requestsList, filteredRequests...)
return removeDuplicates(requestsList), nil
}
// removeDuplicates returns a new slice of APIRequest pointers with
// duplicates removed, keeping only the first occurrence of each
// request based on its ID.
func removeDuplicates(requestsList []*APIRequest) []*APIRequest {
seen := make(map[string]bool, len(requestsList))
unique := make([]*APIRequest, 0, len(requestsList))
for _, req := range requestsList {
if !seen[req.ID] {
seen[req.ID] = true
unique = append(unique, req)
}
}
return unique
}
package loader
import (
"bytes"
"encoding/json"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/sven-seyfert/apiprobe/internal/logger"
"github.com/sven-seyfert/apiprobe/internal/util"
)
// APIRequest represents the structure of each API request definition
// as specified in the input JSON configuration.
type APIRequest struct {
ID string `json:"id"`
IsActive bool `json:"isActive"`
IsAuthRequest bool `json:"isAuthRequest"`
PreRequestID string `json:"preRequestId"`
Request Request `json:"request"`
TestCases []TestCases `json:"testCases"`
Tags []string `json:"tags"`
JqCommand string `json:"jq"`
// Relative JSON file path.
JSONFilePath string `json:"-"`
}
// Request holds the HTTP-specific details for an API request.
type Request struct {
Description string `json:"description"`
Method string `json:"method"`
BaseURL string `json:"url"`
Endpoint string `json:"endpoint"`
BasicAuth string `json:"basicAuth"`
Headers []string `json:"headers"`
Params []string `json:"params"`
PostBodyRaw json.RawMessage `json:"postBody"`
Name string `json:"name"`
// Target data type for the POST body format is string.
PostBody string `json:"-"`
}
// TestCases defines the input variations for the requests.
type TestCases struct {
Name string `json:"name"`
ParamsData string `json:"paramsData"`
PostBodyDataRaw json.RawMessage `json:"postBodyData"`
// Target data type for the POST body format is string.
PostBodyData string `json:"-"`
}
// PreparePostBody prepares the request body (empty, x-www-form-urlencoded
// or compacted JSON). Returns nil on success or an error if JSON compaction fails.
func (req *APIRequest) PreparePostBody() error {
const emptyPostBodyLength = 2
if len(string(req.Request.PostBodyRaw)) == emptyPostBodyLength {
req.Request.PostBody = ""
return nil
}
var buf bytes.Buffer
if err := json.Compact(&buf, req.Request.PostBodyRaw); err != nil {
logger.Errorf("Failed by attempting JSON compact. Error: %v", err)
return err
}
req.Request.PostBody = buf.String()
// Case POST body is JSON.
if !util.ContainsSubstring(req.Request.Headers, "x-www-form-urlencoded") {
return nil
}
// Case POST body form is "x-www-form-urlencoded" which is no JSON.
formURL, err := transformToFormURL(req.Request.PostBody)
if err != nil {
return err
}
req.Request.PostBody = formURL
return nil
}
// PreparePostBodyData processes the raw POST body data of all test cases.
// It normalizes the content based on header type, compacts JSON when needed,
// and sets the processed result into PostBodyData.
// Returns an error if JSON compaction fails, otherwise nil.
func (req *APIRequest) PreparePostBodyData() error {
for idx := range req.TestCases {
testCase := &req.TestCases[idx]
const emptyPostBodyDataLength = 2
if len(string(testCase.PostBodyDataRaw)) == emptyPostBodyDataLength {
testCase.PostBodyData = ""
continue
}
var buf bytes.Buffer
if err := json.Compact(&buf, testCase.PostBodyDataRaw); err != nil {
logger.Errorf("Failed by attempting JSON compact for test case %d. Error: %v", idx, err)
return err
}
testCase.PostBodyData = buf.String()
// Case POST body is JSON.
if !util.ContainsSubstring(req.Request.Headers, "x-www-form-urlencoded") {
continue
}
// Case POST body form is "x-www-form-urlencoded" which is no JSON.
formURL, err := transformToFormURL(testCase.PostBodyData)
if err != nil {
return err
}
testCase.PostBodyData = formURL
}
return nil
}
// transformToFormURL converts a JSON string representing a flat map[string]string
// into a URL-encoded form string and returns the decoded form. An error is
// returned if JSON unmarshalling or URL query unescape fails.
func transformToFormURL(jsonStr string) (string, error) {
var params map[string]string
if err := json.Unmarshal([]byte(jsonStr), ¶ms); err != nil {
logger.Errorf(`Failed to unmarshal JSON "%v".`, err)
return "", err
}
formValues := url.Values{}
for key, value := range params {
formValues.Set(key, value)
}
encodedForm := formValues.Encode()
rawForm, err := url.QueryUnescape(encodedForm)
if err != nil {
logger.Errorf(`Failed to query unescape "%v".`, err)
return "", err
}
return rawForm, nil
}
// BuildRequestURL constructs the full request URL by concatenating the BaseURL,
// Endpoint, and optional query parameters defined in the APIRequest.
func (req *APIRequest) BuildRequestURL() string {
var requestURL strings.Builder
requestURL.WriteString(req.Request.BaseURL)
requestURL.WriteString(req.Request.Endpoint)
if len(req.Request.Params) > 0 {
requestURL.WriteString("?")
requestURL.WriteString(url.PathEscape(strings.Join(req.Request.Params, "&")))
}
return requestURL.String()
}
// CurlCmdArguments builds the command-line arguments for a curl invocation
// based on the HTTP method, URL, headers, authentication and payload
// specified in the APIRequest.
func (req *APIRequest) CurlCmdArguments() []string {
cmdArgs := []string{
"--request", req.Request.Method,
"--silent", "--show-error", "--location", "--insecure",
"--connect-timeout", "8",
"--max-time", "24",
"--url", req.BuildRequestURL(),
"--write-out", "%{http_code}",
}
if req.Request.Method == http.MethodGet {
cmdArgs = append(cmdArgs, "--get")
}
if req.Request.Method == http.MethodPost || req.Request.Method == http.MethodPut {
if req.Request.PostBody != "" {
postBody := req.Request.PostBody
// Encoding for POST/PUT body form "x-www-form-urlencoded".
if util.ContainsSubstring(req.Request.Headers, "x-www-form-urlencoded") {
postBody = url.PathEscape(req.Request.PostBody)
}
cmdArgs = append(cmdArgs, "--data", postBody)
}
}
if req.Request.BasicAuth != "" {
cmdArgs = append(cmdArgs, "--user", req.Request.BasicAuth)
}
for _, header := range req.Request.Headers {
cmdArgs = append(cmdArgs, "--header", header)
}
return cmdArgs
}
// LoadAllRequests recursively walks the input directory, parses all JSON files
// and returns APIRequest pointers.
func LoadAllRequests() ([]*APIRequest, error) {
const inputDir = "./data/input"
var requests []*APIRequest
err := filepath.Walk(inputDir, func(path string, _ os.FileInfo, err error) error {
if err != nil {
logger.Errorf("Failed to walk path. Error: %v", err)
return err
}
if filepath.Ext(path) == ".json" {
fileRequest, loadErr := loadRequestFromFile(path, inputDir)
if loadErr != nil {
return loadErr
}
requests = append(requests, fileRequest...)
}
return nil
})
return requests, err
}
// loadRequestFromFile reads a JSON file, unmarshals it into APIRequest structs
// and assigns the file path.
func loadRequestFromFile(path string, inputDir string) ([]*APIRequest, error) {
bytes, err := os.ReadFile(path)
if err != nil {
logger.Errorf(`Failed to read file "%s". Error: %v`, path, err)
return nil, err
}
var requestData []APIRequest
if err = json.Unmarshal(bytes, &requestData); err != nil {
logger.Errorf(`Failed to unmarshal JSON "%s". Error: %v`, path, err)
return nil, err
}
// Store JSON file path in each request (relative to ./data/input).
relPath, err := filepath.Rel(inputDir, path)
if err != nil {
logger.Errorf(`Failed to get relative path "%s". Error: %v`, path, err)
return nil, err
}
request := make([]*APIRequest, len(requestData))
for idx := range requestData {
requestData[idx].JSONFilePath = relPath
request[idx] = &requestData[idx]
}
return request, nil
}
package logger
import (
"fmt"
"io"
"log" //nolint:depguard
"os"
"path/filepath"
"runtime"
"time"
)
// Init sets up the logger, creates a new log file,
// directs output to both console and file, and
// returns an error if initialization fails.
func Init() error {
now := time.Now()
yearMonth := now.Format("2006-01")
day := now.Format("02")
logsDir := filepath.Join(".", "logs", yearMonth, day)
if err := os.MkdirAll(logsDir, os.ModePerm); err != nil { //nolint:gosec
Errorf(`Failed to create logs directory "%s". Error: %v`, logsDir, err)
return err
}
// Generate unique log file (./logs/2025-06/18/2025-06-18-12-58-54.938.log).
filename := now.Format("2006-01-02-15-04-05.000") + ".log"
logFilePath := filepath.Join(logsDir, filename)
// Open log file.
const permissions = 0o644
logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, permissions)
if err != nil {
Errorf(`Failed to open log file "%s". Error: %v`, logFilePath, err)
return err
}
// Set log output to console and to file.
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
return nil
}
// NewLine logs a blank line without timestamp or prefix.
func NewLine() {
oldFlags := log.Flags()
log.SetFlags(0)
log.Println()
log.SetFlags(oldFlags)
}
// Fatalf logs a formatted fatal message with context.
func Fatalf(format string, args ...interface{}) {
log.Printf("[FATAL] %s %s", fmt.Sprintf(format, args...), occurrence())
}
// Errorf logs a formatted error message with context.
func Errorf(format string, args ...interface{}) {
log.Printf("[ERROR] %s %s", fmt.Sprintf(format, args...), occurrence())
}
// Warnf logs a formatted warning message with context.
func Warnf(format string, args ...interface{}) {
log.Printf("[WARN] %s %s", fmt.Sprintf(format, args...), occurrence())
}
// Infof logs a formatted informational message with context.
func Infof(format string, args ...interface{}) {
log.Printf("[INFO] %s %s", fmt.Sprintf(format, args...), occurrence())
}
// Debugf logs a formatted debug message with context.
func Debugf(format string, args ...interface{}) {
log.Printf("[DEBUG] %s %s", fmt.Sprintf(format, args...), occurrence())
}
// occurrence retrieves the caller’s file and line number
// for log entries.
func occurrence() string {
const skip = 2
_, file, line, ok := runtime.Caller(skip)
if !ok {
return ""
}
return fmt.Sprintf("(%s:%d)\n", filepath.Base(file), line)
}
package report
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"zombiezen.com/go/sqlite"
"github.com/sven-seyfert/apiprobe/internal/config"
"github.com/sven-seyfert/apiprobe/internal/crypto"
"github.com/sven-seyfert/apiprobe/internal/db"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
// Notification sends a summary notification via WebEx webhook.
func Notification(
ctx context.Context,
cfg *config.Config,
conn *sqlite.Conn,
res *Result,
rep *Report,
runName string,
) {
if cfg.Notification.WebEx == nil || !cfg.Notification.WebEx.Active {
return
}
reportFile := buildReportFileName(runName)
hostname, _ := os.Hostname()
hostnameMessage := fmt.Sprintf("Message from: __%s__ (hostname)", hostname)
if res.RequestErrorCount == 0 && res.FormatResponseErrorCount == 0 && res.ChangedFilesCount == 0 {
_ = os.Remove(reportFile)
isHeartbeatTime, err := IsHeartbeatTime(cfg)
if err != nil {
return
}
if !isHeartbeatTime {
return
}
if err = UpdateHeartbeatTime(cfg); err != nil {
return
}
mdMessage := fmt.Sprintf(
`{"markdown":"#### 💙 %s\nHeartbeat: __still alive__\n\n%s"}`,
config.Version,
hostnameMessage,
)
webhookPayload := []byte(mdMessage)
webExWebhookNotification(ctx, conn,
cfg.Notification.WebEx.WebhookURL,
cfg.Notification.WebEx.Space,
webhookPayload)
return
}
if err := rep.SaveToFile(reportFile); err != nil {
logger.Errorf("Error on save file. Error: %v", err)
return
}
data, err := os.ReadFile(reportFile)
if err != nil {
logger.Errorf("Error on read file. Error: %v", err)
return
}
mdCodeBlock := fmt.Sprintf("```json\n%s\n```", data)
testRunName := ""
if runName != "" {
testRunName = fmt.Sprintf("`%s`\n\n", runName)
}
mdResult := fmt.Sprintf(
"%sChanged files: __%d__\nRequest errors: __%d__\nFormat response errors: __%d__\n\n📄 _report.json_",
testRunName,
res.ChangedFilesCount,
res.RequestErrorCount,
res.FormatResponseErrorCount,
)
trafficLight := "🔴"
if res.RequestErrorCount == 0 && res.FormatResponseErrorCount == 0 && res.ChangedFilesCount > 0 {
trafficLight = "🟡"
}
mdMessage := fmt.Sprintf(
"#### %s %s\n%s\n%s\n\n%s",
trafficLight,
config.Version,
mdResult,
mdCodeBlock,
hostnameMessage,
)
payload := map[string]string{
"markdown": mdMessage,
}
webhookPayload, _ := json.Marshal(payload)
webExWebhookNotification(ctx, conn,
cfg.Notification.WebEx.WebhookURL,
cfg.Notification.WebEx.Space,
webhookPayload)
}
// buildReportFileName constructs a sanitized file path for report files
// based on the provided name. It returns the file path as a string.
func buildReportFileName(name string) string {
reportFile := "./logs/report"
if name != "" {
safeName := strings.ToLower(name)
safeName = strings.ReplaceAll(safeName, "environment: ", "")
safeName = strings.ReplaceAll(safeName, " ", "-")
safeName = strings.ReplaceAll(safeName, "\"", "")
safeName = strings.ReplaceAll(safeName, "'", "")
return fmt.Sprintf("./logs/report-%s.json", safeName)
}
return reportFile + ".json"
}
// webExWebhookNotification sends the given JSON payload to the configured
// WebEx incoming webhook URL.
func webExWebhookNotification(
ctx context.Context,
conn *sqlite.Conn,
webhookURL string,
spaceSecret string,
webhookPayload []byte,
) {
url := webhookURL + spaceSecret
const secretPrefix = "<secret-"
if strings.Contains(spaceSecret, secretPrefix) {
spaceSecret = crypto.ExtractSecretHash(spaceSecret)
spaceIdentifier, _ := db.SelectHash(conn, spaceSecret)
url = webhookURL + crypto.Deobfuscate(spaceIdentifier)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(webhookPayload))
if err != nil {
logger.Errorf("Error on new request. Error: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
logger.Errorf("Error on send request. Error: %v", err)
return
}
defer resp.Body.Close()
}
package report
import (
"encoding/json"
"os"
"time"
"github.com/sven-seyfert/apiprobe/internal/config"
"github.com/sven-seyfert/apiprobe/internal/loader"
"github.com/sven-seyfert/apiprobe/internal/logger"
)
type Result struct {
RequestErrorCount int
FormatResponseErrorCount int
ChangedFilesCount int
}
// IncreaseRequestErrorCount increments the Result counter for failed HTTP requests.
func (res *Result) IncreaseRequestErrorCount() {
res.RequestErrorCount++
}
// IncreaseFormatErrorCount increments the Result counter for JSON formatting or jq errors.
func (res *Result) IncreaseFormatErrorCount() {
res.FormatResponseErrorCount++
}
// IncreaseChangedFilesCount increments the Result counter for the number of output files that have changed.
func (res *Result) IncreaseChangedFilesCount() {
res.ChangedFilesCount++
}
type Request struct {
ID string `json:"id"`
Description string `json:"description"`
URL string `json:"url"`
Endpoint string `json:"endpoint"`
StatusCode string `json:"statusCode"`
ErrorResponse string `json:"errorResponse,omitempty"`
TestCase string `json:"testCase,omitempty"`
OutputFile string `json:"outputFile"`
}
type Report struct {
Requests []Request `json:"issues"`
}
// AddReportData records a single API request’s result into the Report.
func (r *Report) AddReportData(
req *loader.APIRequest,
statusCode string,
errorResponse string,
outputFile string,
testCaseIndex int,
) {
const noTestCaseIndicator = -1
testCase := req.Request.Name
if testCaseIndex != noTestCaseIndicator {
testCase = req.TestCases[testCaseIndex].Name
}
request := Request{
ID: req.ID,
Description: req.Request.Description,
URL: req.Request.BaseURL,
Endpoint: req.Request.Endpoint,
StatusCode: statusCode,
ErrorResponse: errorResponse,
TestCase: testCase,
OutputFile: outputFile,
}
r.Requests = append(r.Requests, request)
}
// SaveToFile creates a file with the given name and writes the report as
// pretty-printed JSON. Returns an error if file creation or writing fails.
func (r *Report) SaveToFile(filename string) error {
file, err := os.Create(filename)
if err != nil {
logger.Errorf("Failure on create file. Error: %v", err)
return err
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err = encoder.Encode(r); err != nil {
logger.Errorf("Failure on write file. Error: %v", err)
return err
}
return nil
}
// IsHeartbeatTime checks whether enough time has passed
// since the last heartbeat and returns true if a new
// heartbeat should be sent, or false otherwise,
// along with any error encountered.
func IsHeartbeatTime(cfg *config.Config) (bool, error) {
lastHeartbeatTime := cfg.Heartbeat.LastHeartbeatTime
if lastHeartbeatTime == "" {
return true, nil
}
threshold := time.Hour * time.Duration(cfg.Heartbeat.IntervalInHours)
lastTime, err := time.Parse(time.RFC3339, lastHeartbeatTime)
if err != nil {
logger.Errorf(`Invalid datetime "%s". Error: %v\n`, lastHeartbeatTime, err)
return false, err
}
diff := time.Since(lastTime)
return diff >= threshold, nil
}
// UpdateHeartbeatTime writes the current UTC time (RFC3339) into
// cfg.Heartbeat.LastHeartbeatTime and persists the entire config
// back to the JSON file, returning an error if persistence fails.
func UpdateHeartbeatTime(cfg *config.Config) error {
cfg.Heartbeat.LastHeartbeatTime = time.Now().UTC().Format(time.RFC3339)
file, err := os.Create("./config/apiprobe.json")
if err != nil {
logger.Errorf("Failure on create file. Error: %v", err)
return err
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
encoder.SetEscapeHTML(false)
if err = encoder.Encode(cfg); err != nil {
logger.Errorf("Failure on write file. Error: %v", err)
return err
}
return nil
}
package util
// Max returns the larger of two integer values.
func Max(a, b int) int {
if a > b {
return a
}
return b
}
package util
import (
"strings"
)
// ReplaceQueryParam returns a copy of the given params slice, with the
// key from testCaseValue replaced if present, or appended otherwise.
func ReplaceQueryParam(params []string, testCaseValue string) []string {
const subStringCount = 2
keyToReplace := strings.SplitN(testCaseValue, "=", subStringCount)[0]
replaced := false
newParams := make([]string, len(params))
copy(newParams, params)
for idx, param := range newParams {
parts := strings.SplitN(param, "=", subStringCount)
if len(parts) == subStringCount && parts[0] == keyToReplace {
newParams[idx] = testCaseValue
replaced = true
break
}
}
if !replaced {
newParams = append(newParams, testCaseValue) //nolint:makezero
}
return newParams
}
package util //nolint:revive
import "strings"
// TrimQuotes removes leading and trailing double quotes and trailing
// CRLF from the given string. Returns the cleaned string.
func TrimQuotes(value string) string {
value = strings.TrimPrefix(value, `"`)
value = strings.TrimSuffix(value, "\r\n")
value = strings.TrimSuffix(value, `"`)
return value
}
// ContainsSubstring checks if any string in the slice contains the given substring.
// The comparison is case-insensitive and returns true if found, otherwise false.
func ContainsSubstring(slice []string, substr string) bool {
for _, value := range slice {
if strings.Contains(strings.ToLower(value), strings.ToLower(substr)) {
return true
}
}
return false
}
package main
import (
"context"
"errors"
"os"
"os/signal"
"syscall"
"github.com/sven-seyfert/apiprobe/internal/auth"
"github.com/sven-seyfert/apiprobe/internal/config"
"github.com/sven-seyfert/apiprobe/internal/crypto"
"github.com/sven-seyfert/apiprobe/internal/db"
"github.com/sven-seyfert/apiprobe/internal/exec"
"github.com/sven-seyfert/apiprobe/internal/flags"
"github.com/sven-seyfert/apiprobe/internal/loader"
"github.com/sven-seyfert/apiprobe/internal/logger"
"github.com/sven-seyfert/apiprobe/internal/report"
"zombiezen.com/go/sqlite"
)
// main initializes the logger and database, parses command-line flags loads
// configuration and seeds the database. It then loads and filters API request
// definitions, injects secrets, establishes a cancellation-aware context,
// processes each request and finally sends notifications based on errors
// or detected changes.
func main() {
dbConn, cliFlags, err := initializeServices()
if err != nil {
logger.Fatalf("Program exits: %v", err)
return
}
defer dbConn.Close()
cfg, err := config.Load("./config/apiprobe.json")
if err != nil {
logger.Fatalf("Program exits: Failed to load config file.")
return
}
// Handle command-line flags.
complete, err := flags.IsNewID(*cliFlags.NewID)
if complete || err != nil {
return
}
complete, err = flags.IsNewFile(*cliFlags.NewFile)
if complete || err != nil {
return
}
complete, err = flags.IsAddSecret(*cliFlags.AddSecret, dbConn)
if complete || err != nil {
return
}
// Fill database with default seed data.
err = db.InsertSeedData(dbConn)
if err != nil {
logger.Fatalf("Program exits: Failed to fill database with seed default data.")
return
}
// Load requests from JSON files in the input directory.
requests, err := loader.LoadAllRequests()
if err != nil {
logger.Fatalf("Program exits: Failed to load API request definitions.")
return
}
// Exclude requests based on IDs.
filteredRequests := loader.ExcludeRequestsByID(requests, *cliFlags.Exclude)
// Filter requests based on single id (ten character long hex hash) or by flags.
filteredRequests, notFound := loader.FilterRequests(filteredRequests, *cliFlags.ID, *cliFlags.Tags)
if notFound {
return
}
// Merge possible pre-requests (prepend) with the filtered requests.
preparedRequests, err := loader.MergePreRequests(requests, filteredRequests)
if err != nil {
logger.Fatalf("Program exits: Failed to gather pre-requests.")
return
}
// Prepare the requests by compacting the JSON POST body,
// handling "x-www-form-urlencoded" and POST body test cases.
for _, req := range preparedRequests {
if err = req.PreparePostBody(); err != nil {
logger.Fatalf("Program exits: Failed to prepare the POST body.")
}
if err = req.PreparePostBodyData(); err != nil {
logger.Fatalf("Program exits: Failed to prepare the POST body test cases.")
}
}
// Replace secrets placeholders in the requests with actual values.
finalRequests, err := crypto.HandleSecrets(preparedRequests, dbConn)
if err != nil {
logger.Fatalf("Program exits: Failed to handle secrets in requests.")
return
}
// Only once requests are loaded successfully, set up signal-cancellation context.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Initializes token store.
tokenStore := auth.NewTokenStore()
// Process each API request, optionally with test case variations.
res, rep := processRequests(ctx, finalRequests, tokenStore, cfg.DebugMode)
// Send notification on error case or on changes.
report.Notification(ctx, cfg, dbConn, res, rep, *cliFlags.Name)
}
// initializeServices initializes logger, database and CLI flags.
// Returns database connection, CLI flags and error if initialization fails.
func initializeServices() (*sqlite.Conn, *flags.CLIFlags, error) {
if err := logger.Init(); err != nil {
return nil, nil, errors.Join(errors.New("failed to initialize logger: "), err)
}
conn, err := db.Init()
if err != nil {
return nil, nil, errors.Join(errors.New("failed to initialize database: "), err)
}
cliFlags := flags.Init()
return conn, cliFlags, nil
}
// processRequests iterates over the APIRequests, executes
// each (including test cases), and writes the results. It returns
// the aggregated Result and Report.
func processRequests(
ctx context.Context,
requests []*loader.APIRequest,
tokenStore *auth.TokenStore,
debugMode bool,
) (*report.Result, *report.Report) {
res := &report.Result{}
rep := &report.Report{}
for idx, req := range requests {
if ctx.Err() != nil {
logger.Debugf("Received cancellation signal. Stopping request processing.")
return res, rep
}
if !req.IsActive {
continue
}
if idx > 0 {
logger.NewLine()
}
logger.Infof(`Run: %d, Test case: %d, File: "%s"`, idx+1, 0, req.JSONFilePath)
if req.PreRequestID != "" {
auth.RepaceAuthTokenPlaceholderInRequestHeader(req, tokenStore)
}
// Execute first (main) request, regardless of whether additional test cases exist.
exec.ProcessFirstRequest(ctx, idx+1, req, nil, res, rep, tokenStore, debugMode)
// Execute additional requests of the same JSON definition file,
// depending on the number of defined test cases.
exec.ProcessTestCasesRequests(ctx, req, idx, res, rep, tokenStore, debugMode)
}
return res, rep
}