DEV Community

Philip Perry
Philip Perry

Posted on • Edited on

Adding filter query parameters in Go Huma

From what I have been able to find out, Huma unfortunately doesn't support array query filters like this: filters[]=filter1&filters[]=filter2 (neither leaving the brackets out, e.g. filter=filter1&filter=filter2). I came across this Github issue that gives an example of separating the filters by comma https://github.com/danielgtaylor/huma/issues/325, so that's what we ended up doing: filters=postcode:eq:RM7%28EX,created:gt:2024-01-01

I found a way to use repeated query parameters, so that I don't need to separate them by comma after all. It can be done using the explode option in the struct tag. So below you will find the updated implementation (I deleted the old one).

The query string will look like this: filters=postcode:eq:RM7%28EX&filters=created:gt:2024-01-01

Documenting filters

Unlike the body parameters, which one can simply specify as structs and then they get both validated and generated in the documentation, the documentation and validation for filters has to be done separately.

The documentation can simply be added under the description attribute of the Huma.Param object (under Operation):

Parameters: []*huma.Param{{
            Name: "filters",
            In:   "query",
            Description: "Filter properties by various fields.\n\n" +
                "Format: field:operator:value\n\n" +
                "Supported fields:\n" +
                "- postcode (operator: eq)\n" +
                "- created (operators: gt, lt, gte, lte)\n",
            Schema: &huma.Schema{
                Type: "string",
                Items: &huma.Schema{
                    Type:    "string",
                    Pattern: "^[a-zA-Z_]+:(eq|neq|gt|lt|gte|lte):[a-zA-Z0-9-:.]+$",
                },
                Examples: []any{
                    "postcode:eq:RM7 8EX",
                    "created:gt:2024-01-01",
                },
            },
            Required: false,
        }},
Enter fullscreen mode Exit fullscreen mode

Image description

We can now define our PropertyFilterParams struct for validation:

type FilterParam struct {
    Field    string
    Operator string
    Value    interface{}
}

type PortfolioFilterParams struct {
    Items []FilterParam
}

func (input *PortfolioQueryParams) Resolve(ctx huma.Context) []error {
    equalityFields := []string{"property_id", "external_id", "address", "action_text"}
    greaterSmallerFields := []string{"households", "occupants"}
    dateFields := []string{}

    var errs []error
    for idx, f := range input.Filters {
        _, err := parseAndValidateFilterItem(f, equalityFields, greaterSmallerFields, dateFields)
        if err != nil {
            errs = append(errs, &huma.ErrorDetail{
                Location: fmt.Sprintf("query.filters[%d]", idx),
                Message:  err.Error(),
                Value:    f,
            })
        }
    }

    return errs

}

func (s *PortfolioFilterParams) Schema(registry huma.Registry) *huma.Schema {
    return &huma.Schema{
        Type: huma.TypeString,
    }
}

func parseAndValidateFilterItem(item string, equalityFields []string, greaterSmallerFields []string, dateFields []string) (FilterParam, error) {
    parts := strings.SplitN(item, ":", 3)

    field := parts[0]
    operator := parts[1]
    value := parts[2]

    if contains(equalityFields, field) {
        if operator != "eq" && operator != "neq" {
            return FilterParam{}, fmt.Errorf("Unsupported operator %s for field %s. Only 'eq' and 'neq' are supported.", operator, field)
        }
    } else if contains(greaterSmallerFields, field) {
        if !validation.IsValidCompareGreaterSmallerOperator(operator) {
            return FilterParam{}, fmt.Errorf("Unsupported operator %s for field %s. Supported operators: eq, neq, gt, lt, gte, lte.", operator, field)
        }
    } else if contains(dateFields, field) {
        if !validation.IsValidCompareGreaterSmallerOperator(operator) {
            return FilterParam{}, fmt.Errorf("Unsupported operator %s for field %s. Supported operators: eq, neq, gt, lt, gte, lte.", operator, field)
        }
        if !validation.IsValidDate(value) {
            return FilterParam{}, fmt.Errorf("Invalid date format: %s. Expected: YYYY-MM-DD", value)
        }
    } else {
        return FilterParam{}, fmt.Errorf("Unsupported filter field: %s", field)
    }

    return FilterParam{Field: field, Operator: operator, Value: value}, nil
}
Enter fullscreen mode Exit fullscreen mode

And this is how the PortfolioQueryParams struct looks like:

type PortfolioQueryParams struct {
    PaginationParams
    Filters []string `query:"filters,explode" doc:"Filter portfolio by various fields"`
}
Enter fullscreen mode Exit fullscreen mode

This is how adding PortfolioQueryParams to the route looks like (note that the Operation code itself, including the filter description, is under getAllPropertyOperation - I didn't paste the complete code for that, but hopefully you get the gist of it). If validation fails, it will throw a 422 response. I also added how we can loop through the filter values that got passed:

huma.Register(api, getAllPropertyOperation(schema, "get-properties", "/properties", []string{"Properties"}),
        func(ctx context.Context, input *struct {
            models.Headers
            models.PortfolioQueryParams
        }) (*models.MultiplePropertyOutput, error) {

            for _, filter := range input.Filters{
                fmt.Println(filter)
            }

            return mockMultiplePropertyResponse(), err
        })
}
Enter fullscreen mode Exit fullscreen mode

I hope this helps someone. Let me know in the comments, if you found a better solution.

Top comments (0)