fintual-backend-devops-go / go-service / validation.go
validation.go
Raw
package main

import (
	"encoding/json"
	"fmt"
	"math"
	"math/big"
	"strconv"
	"strings"
)

type Input struct {
	AuthorID       int64
	AuthorOverflow bool
	Title, Body    string
	Tags           []string
}
type Validation struct {
	Type string `json:"type"`
	Loc  []any  `json:"loc"`
	Msg  string `json:"msg"`
}

var messages = map[string]string{"missing": "Field required", "int_parsing": "Input should be a valid integer, unable to parse string as an integer", "int_type": "Input should be a valid integer", "int_from_float": "Input should be a valid integer, got a number with a fractional part", "string_type": "Input should be a valid string", "list_type": "Input should be a valid list"}

func issue(kind string, loc ...any) Validation { return Validation{kind, loc, messages[kind]} }
func integer(value any) (int64, string) {
	switch v := value.(type) {
	case bool:
		if v {
			return 1, ""
		}
		return 0, ""
	case json.Number:
		if n, err := v.Int64(); err == nil {
			return n, ""
		}
		if z, ok := new(big.Int).SetString(string(v), 10); ok && !z.IsInt64() {
			return 0, "overflow"
		}
		n, err := v.Float64()
		if err != nil || math.IsNaN(n) || math.IsInf(n, 0) || n != math.Trunc(n) {
			return 0, "int_from_float"
		}
		if n >= math.MaxInt64 || n < math.MinInt64 {
			return 0, "overflow"
		}
		return int64(n), ""
	case string:
		v = strings.TrimSpace(v)
		for i, c := range v {
			if c == '_' && (i == 0 || i+1 == len(v) || v[i-1] < '0' || v[i-1] > '9' || v[i+1] < '0' || v[i+1] > '9') {
				return 0, "int_parsing"
			}
		}
		v = strings.ReplaceAll(v, "_", "")
		if n, err := strconv.ParseInt(v, 10, 64); err == nil {
			return n, ""
		}
		if z, ok := new(big.Int).SetString(v, 10); ok && !z.IsInt64() {
			return 0, "overflow"
		}
		// Pydantic accepts integral decimal strings, but not exponent notation.
		if strings.Contains(v, ".") && !strings.ContainsAny(v, "eE") {
			parts := strings.Split(v, ".")
			if len(parts) == 2 && parts[0] != "" && parts[1] != "" && strings.Trim(parts[1], "0") == "" {
				if n, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
					return n, ""
				}
			}
		}
		return 0, "int_parsing"
	default:
		return 0, "int_type"
	}
}
func validate(body []byte, post bool) (Input, []Validation, error) {
	result := Input{Tags: []string{}}
	issues := []Validation{}
	if len(body) == 0 {
		return result, []Validation{issue("missing", "body", "payload")}, nil
	}
	if !json.Valid(body) {
		return result, nil, pythonJSONError(body)
	}
	var value any
	d := json.NewDecoder(strings.NewReader(string(body)))
	d.UseNumber()
	if err := d.Decode(&value); err != nil {
		return result, nil, err
	}
	m, _ := value.(map[string]any)
	fields := []string{"author_id"}
	if post {
		fields = append(fields, "title")
	}
	fields = append(fields, "body")
	for _, field := range fields {
		val, ok := m[field]
		if !ok {
			// Ninja's from-attributes adapter resolves str.title() on a string body.
			if _, isString := value.(string); isString && field == "title" {
				continue
			}
			issues = append(issues, issue("missing", "body", "payload", field))
			continue
		}
		if field == "author_id" {
			n, kind := integer(val)
			if kind != "" {
				if kind == "overflow" {
					result.AuthorOverflow = true
				} else {
					issues = append(issues, issue(kind, "body", "payload", field))
				}
			} else {
				result.AuthorID = n
			}
			continue
		}
		str, ok := val.(string)
		if !ok {
			issues = append(issues, issue("string_type", "body", "payload", field))
		} else if field == "title" {
			result.Title = str
		} else {
			result.Body = str
		}
	}
	if post {
		if tags, exists := m["tag_slugs"]; exists {
			values, ok := tags.([]any)
			if !ok {
				issues = append(issues, issue("list_type", "body", "payload", "tag_slugs"))
			} else {
				for i, v := range values {
					str, ok := v.(string)
					if !ok {
						issues = append(issues, issue("string_type", "body", "payload", "tag_slugs", i))
					} else {
						result.Tags = append(result.Tags, str)
					}
				}
			}
		}
	}
	return result, issues, nil
}

// Match the reference JSON decoder's diagnostic vocabulary and character offsets.
// This affects compatibility errors only; successful input uses encoding/json.
func pythonJSONError(raw []byte) error {
	var value any
	err := json.Unmarshal(raw, &value)
	syntax, ok := err.(*json.SyntaxError)
	if !ok {
		return fmt.Errorf("Expecting value: line 1 column 1 (char 0)")
	}
	pos := max(0, int(syntax.Offset)-1)
	message := "Expecting value"
	text := syntax.Error()
	switch {
	case strings.Contains(text, "looking for beginning of object key string"):
		message = "Expecting property name enclosed in double quotes"
	case strings.Contains(text, "after object key"):
		message = "Expecting ':' delimiter"
		if strings.Contains(text, "key:value") {
			message = "Expecting ',' delimiter"
		}
	case strings.Contains(text, "after array element"):
		message = "Expecting ',' delimiter"
	case strings.Contains(text, "after top-level value"):
		message = "Extra data"
	case strings.Contains(text, "escape code"):
		message = `Invalid \escape`
		pos = max(0, pos-1)
	case strings.Contains(text, "in string literal"):
		message = "Invalid control character at"
	case strings.Contains(text, "unexpected end"):
		pos = len(raw)
		inString := false
		escaped := false
		start := 0
		for i, c := range raw {
			if escaped {
				escaped = false
				continue
			}
			if inString && c == '\\' {
				escaped = true
				continue
			}
			if c == '"' {
				inString = !inString
				if inString {
					start = i
				}
			}
		}
		trimmed := strings.TrimSpace(string(raw))
		if inString {
			message = "Unterminated string starting at"
			pos = start
		} else if strings.HasSuffix(trimmed, "{") || strings.HasSuffix(trimmed, ",") {
			message = "Expecting property name enclosed in double quotes"
		} else if strings.HasSuffix(trimmed, "\"") {
			message = "Expecting ':' delimiter"
		}
	}
	// Python counts Unicode characters, not UTF-8 bytes.
	prefix := []rune(string(raw[:min(pos, len(raw))]))
	line, column := 1, 1
	for _, r := range prefix {
		if r == '\n' {
			line++
			column = 1
		} else {
			column++
		}
	}
	return fmt.Errorf("%s: line %d column %d (char %d)", message, line, column, len(prefix))
}