Files
cv-site/internal/models/cv/validation.go
T
juanatsap 9240a863d1 refactor: Extract shared utilities and add validation layer
Part 1: Shared Utilities
- Create internal/fileutil package with FindDataFile() and LoadJSON()
- Create internal/lang package with language constants and validation
- Eliminate 46 lines of code duplication between cv/loader.go and ui/loader.go
- Simplify cv/loader.go from 69 to 36 lines (-48%)
- Simplify ui/loader.go from 56 to 24 lines (-57%)

Part 2: Validation Layer
- Add comprehensive validation in internal/models/cv/validation.go
- Validate Personal (name, email format, URLs)
- Validate Experience (required fields, dates)
- Validate Education (required fields)
- Validate Skills (proficiency ranges 1-5, categories)
- Validate Languages (proficiency levels 1-5)
- Validate Projects (title, URLs)
- Validate Meta (version, language)
- Integrate validation into LoadCV() - automatic on load
- Create ValidationError and ValidationErrors types for clear error reporting
- Report all validation errors at once (better UX)

Testing:
- Add comprehensive tests for fileutil package (FindDataFile, LoadJSON)
- Add tests for lang package (IsValid, Validate, All)
- Add 280+ validation test cases covering edge cases
- All tests pass with real CV data (cv-en.json, cv-es.json)
- Fixed validation to allow both URLs and local paths for gitRepoUrl

Documentation:
- Create _go-learning/refactorings/002-shared-utilities-validation.md
- Document architecture, benefits, testing, and interview talking points
- Explain WHY decisions were made (DRY, type safety, data integrity)

Benefits:
- DRY: Single source of truth for utilities
- Type safety: Language constants instead of magic strings
- Data integrity: Validation catches errors at load time
- Better errors: Clear messages showing all issues at once
- Maintainability: Centralized utilities easier to update
2025-11-20 16:41:13 +00:00

379 lines
9.7 KiB
Go

package cv
import (
"fmt"
"net/mail"
"net/url"
"strings"
)
// ValidationError represents a validation error with context
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// ValidationErrors is a collection of validation errors
type ValidationErrors []ValidationError
func (e ValidationErrors) Error() string {
if len(e) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("validation failed:\n")
for _, err := range e {
sb.WriteString(" - ")
sb.WriteString(err.Error())
sb.WriteString("\n")
}
return sb.String()
}
// Validate performs comprehensive validation on the CV
func (cv *CV) Validate() error {
var errors ValidationErrors
// Validate Personal section
if err := cv.Personal.Validate(); err != nil {
if verrs, ok := err.(ValidationErrors); ok {
errors = append(errors, verrs...)
} else {
errors = append(errors, ValidationError{Field: "personal", Message: err.Error()})
}
}
// Validate Experience entries
for i, exp := range cv.Experience {
if err := exp.Validate(); err != nil {
if verrs, ok := err.(ValidationErrors); ok {
for _, verr := range verrs {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("experience[%d].%s", i, verr.Field),
Message: verr.Message,
})
}
} else {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("experience[%d]", i),
Message: err.Error(),
})
}
}
}
// Validate Education entries
for i, edu := range cv.Education {
if err := edu.Validate(); err != nil {
if verrs, ok := err.(ValidationErrors); ok {
for _, verr := range verrs {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("education[%d].%s", i, verr.Field),
Message: verr.Message,
})
}
} else {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("education[%d]", i),
Message: err.Error(),
})
}
}
}
// Validate Skills
if err := cv.Skills.Validate(); err != nil {
if verrs, ok := err.(ValidationErrors); ok {
errors = append(errors, verrs...)
} else {
errors = append(errors, ValidationError{Field: "skills", Message: err.Error()})
}
}
// Validate Languages
for i, lang := range cv.Languages {
if err := lang.Validate(); err != nil {
if verrs, ok := err.(ValidationErrors); ok {
for _, verr := range verrs {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("languages[%d].%s", i, verr.Field),
Message: verr.Message,
})
}
} else {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("languages[%d]", i),
Message: err.Error(),
})
}
}
}
// Validate Projects
for i, proj := range cv.Projects {
if err := proj.Validate(); err != nil {
if verrs, ok := err.(ValidationErrors); ok {
for _, verr := range verrs {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("projects[%d].%s", i, verr.Field),
Message: verr.Message,
})
}
} else {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("projects[%d]", i),
Message: err.Error(),
})
}
}
}
// Validate Meta (essential CV metadata)
if err := cv.Meta.Validate(); err != nil {
if verrs, ok := err.(ValidationErrors); ok {
errors = append(errors, verrs...)
} else {
errors = append(errors, ValidationError{Field: "meta", Message: err.Error()})
}
}
if len(errors) > 0 {
return errors
}
return nil
}
// Validate checks Personal information
func (p *Personal) Validate() error {
var errors ValidationErrors
if strings.TrimSpace(p.Name) == "" {
errors = append(errors, ValidationError{Field: "name", Message: "name is required"})
}
if strings.TrimSpace(p.Email) == "" {
errors = append(errors, ValidationError{Field: "email", Message: "email is required"})
} else if !isValidEmail(p.Email) {
errors = append(errors, ValidationError{Field: "email", Message: "invalid email format"})
}
// Validate URLs if present
if p.LinkedIn != "" && !isValidURL(p.LinkedIn) {
errors = append(errors, ValidationError{Field: "linkedin", Message: "invalid URL format"})
}
if p.GitHub != "" && !isValidURL(p.GitHub) {
errors = append(errors, ValidationError{Field: "github", Message: "invalid URL format"})
}
if p.Website != "" && !isValidURL(p.Website) {
errors = append(errors, ValidationError{Field: "website", Message: "invalid URL format"})
}
if p.Domestika != "" && !isValidURL(p.Domestika) {
errors = append(errors, ValidationError{Field: "domestika", Message: "invalid URL format"})
}
if len(errors) > 0 {
return errors
}
return nil
}
// Validate checks Experience entry
func (e *Experience) Validate() error {
var errors ValidationErrors
if strings.TrimSpace(e.Position) == "" {
errors = append(errors, ValidationError{Field: "position", Message: "position is required"})
}
if strings.TrimSpace(e.Company) == "" {
errors = append(errors, ValidationError{Field: "company", Message: "company is required"})
}
if strings.TrimSpace(e.StartDate) == "" {
errors = append(errors, ValidationError{Field: "startDate", Message: "start date is required"})
}
// EndDate is only required if not current
if !e.Current && strings.TrimSpace(e.EndDate) == "" {
errors = append(errors, ValidationError{Field: "endDate", Message: "end date is required when current is false"})
}
// Validate company URL if present
if e.CompanyURL != "" && !isValidURL(e.CompanyURL) {
errors = append(errors, ValidationError{Field: "companyURL", Message: "invalid URL format"})
}
if len(errors) > 0 {
return errors
}
return nil
}
// Validate checks Education entry
func (e *Education) Validate() error {
var errors ValidationErrors
if strings.TrimSpace(e.Degree) == "" {
errors = append(errors, ValidationError{Field: "degree", Message: "degree is required"})
}
if strings.TrimSpace(e.Institution) == "" {
errors = append(errors, ValidationError{Field: "institution", Message: "institution is required"})
}
if strings.TrimSpace(e.StartDate) == "" {
errors = append(errors, ValidationError{Field: "startDate", Message: "start date is required"})
}
if strings.TrimSpace(e.EndDate) == "" {
errors = append(errors, ValidationError{Field: "endDate", Message: "end date is required"})
}
if len(errors) > 0 {
return errors
}
return nil
}
// Validate checks Skills section
func (s *Skills) Validate() error {
var errors ValidationErrors
for i, cat := range s.Technical {
if strings.TrimSpace(cat.Category) == "" {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("technical[%d].category", i),
Message: "category name is required",
})
}
// Proficiency should be between 1-5 (typical skill rating)
if cat.Proficiency < 1 || cat.Proficiency > 5 {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("technical[%d].proficiency", i),
Message: "proficiency must be between 1 and 5",
})
}
if len(cat.Items) == 0 {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("technical[%d].items", i),
Message: "at least one skill item is required",
})
}
// Validate sidebar value
if cat.Sidebar != "" && cat.Sidebar != "left" && cat.Sidebar != "right" {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("technical[%d].sidebar", i),
Message: "sidebar must be 'left', 'right', or empty",
})
}
}
if len(errors) > 0 {
return errors
}
return nil
}
// Validate checks Language entry
func (l *Language) Validate() error {
var errors ValidationErrors
if strings.TrimSpace(l.Language) == "" {
errors = append(errors, ValidationError{Field: "language", Message: "language name is required"})
}
if strings.TrimSpace(l.Proficiency) == "" {
errors = append(errors, ValidationError{Field: "proficiency", Message: "proficiency is required"})
}
// Level should be between 1-5 (typical language proficiency scale)
if l.Level < 1 || l.Level > 5 {
errors = append(errors, ValidationError{Field: "level", Message: "level must be between 1 and 5"})
}
if len(errors) > 0 {
return errors
}
return nil
}
// Validate checks Project entry
func (p *Project) Validate() error {
var errors ValidationErrors
if strings.TrimSpace(p.Title) == "" {
errors = append(errors, ValidationError{Field: "title", Message: "title is required"})
}
// Validate URL if present
if p.URL != "" && !isValidURL(p.URL) {
errors = append(errors, ValidationError{Field: "url", Message: "invalid URL format"})
}
// GitRepoUrl can be either a URL or a local filesystem path
// We don't validate it strictly since it supports both formats
if len(errors) > 0 {
return errors
}
return nil
}
// Validate checks Meta information
func (m *Meta) Validate() error {
var errors ValidationErrors
if strings.TrimSpace(m.Version) == "" {
errors = append(errors, ValidationError{Field: "version", Message: "version is required"})
}
if strings.TrimSpace(m.Language) == "" {
errors = append(errors, ValidationError{Field: "language", Message: "language is required"})
}
if len(errors) > 0 {
return errors
}
return nil
}
// Helper functions
// isValidEmail checks if an email address is valid
func isValidEmail(email string) bool {
email = strings.TrimSpace(email)
if email == "" {
return false
}
_, err := mail.ParseAddress(email)
return err == nil
}
// isValidURL checks if a URL is valid
func isValidURL(urlStr string) bool {
urlStr = strings.TrimSpace(urlStr)
if urlStr == "" {
return false
}
// Parse the URL
u, err := url.Parse(urlStr)
if err != nil {
return false
}
// Check that it has a scheme (http, https, etc.)
if u.Scheme == "" {
return false
}
// Check that it has a host
if u.Host == "" {
return false
}
return true
}