Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validation errors localization #414

Open
lordspinach opened this issue Apr 26, 2024 · 3 comments
Open

Validation errors localization #414

lordspinach opened this issue Apr 26, 2024 · 3 comments
Labels
enhancement New feature or request

Comments

@lordspinach
Copy link
Contributor

Hello!

I'm facing a problem, I want to localize my application based on HUMA and for that I have to implement Registry, Schema and PathBuffer with almost identical logic in the validation part. Another solution is to disable HUMA validation altogether and implement something else. So my question is. Do you plan to implement internationalization in HUMA? Maybe you already have some thoughts on this and would be willing to share them so I can help you implement this feature. Or, conversely, why you don't want to do it. I think this feature would be awesome and am open to discussing it.

Best regards

@lordspinach lordspinach changed the title Validation errors localisation Validation errors localization Apr 27, 2024
@danielgtaylor
Copy link
Owner

@lordspinach I have zero experience doing i18n in Go. I've done it in Python programs in the past using gettext, but am not entirely sure where to start, so I'm open to discussion on this one. I think it would be nice:

  • Use Accept-Language header to let clients request a language
  • Use existing content negotiation utilities to make this possible. This also means we probably need access to the request context in some new places in the code.
  • Return a Content-Language response header when appropriate
  • Do not pull in a bunch of new dependencies. In fact I'd rather see code generation used if possible or vendoring some tiny lib to load strings instead of dependencies.
  • We need to do this in a way that keeps memory allocations out of the hot path of request handling, e.g. precomputing/loading as much as possible on startup.
  • We'll need some volunteers for initial translations.

Thanks!

@danielgtaylor danielgtaylor added the enhancement New feature or request label Apr 28, 2024
@lordspinach
Copy link
Contributor Author

lordspinach commented May 3, 2024

@danielgtaylor As an example solution i can suggest something like this from my current app:

//go:embed locales/*/data.yaml
var f embed.FS

// langMap is a map that contains supported locales in [en][validation][unexpected property] format
var langMap = make(map[string]map[string]map[string]string)

// LoadLocalesMap loads the language locales into the map variable by parsing the YAML data files.
func LoadLocalesMap() error {
	err := parseLocalesToMap()
	if err != nil {
		return errors.Wrap(err, "failed to parse locales")
	}
	return nil
}

func T(ctx context.Context, path string, args ...any) string {
	localeMap := ctx.Value("locale").(map[string]map[string]string)
	p := strings.Split(path, ":")
	category, key := p[0], p[1]
	translation, ok := localeMap[category][key]
	if !ok {
		return ""
	}
	return fmt.Sprintf(translation, args...)
}

func GetSupportedLanguages() []string {
	supportedLanguages := make([]string, 0, len(langMap))
	for lang := range langMap {
		supportedLanguages = append(supportedLanguages, lang)
	}
	return supportedLanguages
}

func GetLocaleMap(lang string) map[string]map[string]string {
	return langMap[lang]
}

func parseLocalesToMap() error {
	rootDir, err := f.ReadDir("locales")
	if err != nil {
		return gerro.Internal.Wrap(err, "can't read locales")
	}
	for _, locale := range rootDir {
		file, err := f.ReadFile(fmt.Sprintf("locales/%s/data.yaml", locale.Name()))
		if err != nil {
			return errors.Wrap(err, fmt.Sprintf("can't read locales/%s/data.yaml", locale.Name()))
		}
		localeData := make(map[string]map[string]string)
		err = yaml.Unmarshal(file, &localeData)
		if err != nil {
			return errors.Wrap(err, fmt.Sprintf("can't parse locales/%s/data.yaml", locale.Name()))
		}
		langMap[locale.Name()] = localeData
	}
	return nil
}

Here is an example of usage:

return huma.Error400BadRequest(i18n.T(ctx, "validation:Several validation errors occurred"), details...)

This implementation pretend to use ctx for load any of locale. As we load all locales into map at app start we keeps allocations on request at the same level. It allows us to get locale based on request's Accept-Language and send corresponding Content-Language. Also i think we should choose something else then embed fs for locales reading as it not support dynamic path change and it will make impossible to reassign locales in peoples apps.
I'm not in full dive into huma and not sure is it ok to send context to such a lot of a places and hope to get your thoughts on my suggestion

@krisatverbidio
Copy link

The way I approached it is that I overrode the error messages that I was using with translation keys:

func overrideHumaValidationMessages() {
        validation.MsgExpectedMinimumNumber = "#Translate#ErrorExpectedMinimumNumber,%v#"                   // "expected number >= %v"
        validation.MsgExpectedExclusiveMinimumNumber = "#Translate#ErrorExpectedExclusiveMinimumNumber,%v#" // "expected number > %v"
        validation.MsgExpectedMaximumNumber = "#Translate#ErrorExpectedMaximumNumber,%v#"                   // "expected number <= %v"
        validation.MsgExpectedExclusiveMaximumNumber = "#Translate#ErrorExpectedExclusiveMaximumNumber,%v#" // "expected number < %v"
        validation.MsgExpectedNumberBeMultipleOf = "#Translate#ErrorExpectedNumberBeMultipleOf,%v#"         // "expected number to be a multiple of %v"
        validation.MsgExpectedMinLength = "#Translate#ErrorExpectedMinLength,%d#"                           // "expected length >= %d"
        validation.MsgExpectedMaxLength = "#Translate#ErrorExpectedMaxLength,%d#"                           // "expected length <= %d"
        validation.MsgExpectedRFC5322Email = "#Translate#ErrorExpectedEmail,%v#"                            // "expected string to be RFC 5322 email: %v""
}

I then created a transformer:

func ErrorResponseTranslationTransformer(ctx huma.Context, status string, v any) (any, error) {
        statusInt, err := strconv.Atoi(status)
        if err != nil {
                panic(fmt.Sprintf("invalid status %s", status))
        }

        // no error? then who cares
        if statusInt < 400 {
                return v, nil
        }

        var apiErr *types.ApiErrorModel
        var ok bool
        if apiErr, ok = v.(*types.ApiErrorModel); !ok {
                return v, nil
        }

        baseCtx := ctx.Context()

        // loop through things and replace
        {
                apiErr.Code = translateErrors(baseCtx, apiErr.Code)
                apiErr.Message = translateErrors(baseCtx, apiErr.Message)
                apiErr.Detail = translateErrors(baseCtx, apiErr.Detail)

                // .Errors
                for x, thisErr := range apiErr.Errors {
                        thisErr.Message = translateErrors(baseCtx, thisErr.Message)
                        apiErr.Errors[x] = thisErr
                }

                // .Fields recursively
                apiErr.Fields = translateFields(baseCtx, apiErr.Fields)
        }

        return apiErr, nil
}

var translatePattern = regexp.MustCompile(`#Translate#(\w+)(?:,([^#]+))?#`)

// translateErrors uses the above messages and searches and replaces for them. for example, it will look for
// "#Translate#ErrorExpectedMaxLength,6#" and replace it with the results of
func translateErrors(ctx context.Context, message string) string {
        // Find all matches
        matches := translatePattern.FindAllStringSubmatch(message, -1)

        for _, match := range matches {
                var errorKey string
                params := map[string]string{}

                if len(match) >= 2 {
                        // Extract the key
                        errorKey = match[1]
                        // Extract the parameter, if present
                        param := ""
                        if len(match) == 3 && match[2] != "" {
                                param = match[2]
                        }

                        // Prepare the translation params
                        if param != "" {
                                params["n"] = param
                                params["s"] = param
                        }

                        // Perform the translation (assuming apitranslator.TranslateWithParams exists)
                        translatedMessage := apitranslator.TranslateWithParams(ctx, errorKey, params)

                        // Replace the entire tag with the translated message
                        message = strings.ReplaceAll(message, match[0], translatedMessage)
                }
        }

        return message
}

and then added that transformer to the huma config.

config.Transformers = append(config.Transformers, ErrorResponseTranslationTransformer)

Felt a bit hacky, but it worked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants