Skip to content

Commit

Permalink
Merge pull request #176 from danielgtaylor/custom-errors
Browse files Browse the repository at this point in the history
fix: support custom errors with private fields; docs updates
  • Loading branch information
danielgtaylor authored Nov 20, 2023
2 parents be1f1da + a88ac66 commit 23c78a4
Show file tree
Hide file tree
Showing 9 changed files with 402 additions and 95 deletions.
110 changes: 103 additions & 7 deletions docs/docs/features/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,29 @@ description: Register operations to handle incoming requests.

## Operations { .hidden }

Operations are at the core of Huma. They map an HTTP method verb and resource path to a handler function with well-defined inputs and outputs. Operations are created using the [`huma.Register`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Register) function:
Operations are at the core of Huma. They map an HTTP method verb and resource path to a handler function with well-defined inputs and outputs. When looking at an API made up of resources, the operations correspond to the `GET`, `POST`, `PUT`, etc methods on those resources like in the example below:

```mermaid
graph TD
subgraph Operations
GET
GET2[GET]
POST
PUT
DELETE
end
API --> Resource1[Resource /items]
API --> Resource2["Resource /users/{user-id}"]
Resource1 --> POST
Resource1 --> GET
Resource1 --> DELETE
Resource2 --> GET2
Resource2 --> PUT
```

Operations are created using the [`huma.Register`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Register) function:

```go
huma.Register(api, huma.Operation{
Expand All @@ -23,23 +45,97 @@ huma.Register(api, huma.Operation{

If following REST-ish conventions, operation paths should be nouns, and plural if they return more than one item. Good examples: `/notes`, `/likes`, `/users/{user-id}`, `/videos/{video-id}/stats`, etc. Huma does not enforce this or care, so RPC-style paths are also fine to use. Use what works best for you and your team.

!!! info "OperationID"

Did you know? The `OperationID` is used to generate friendly CLI commands in [Restish](https://rest.sh/) and used when generating SDKs! It should be unique, descriptive, and easy to type.

```sh title="Terminal"
$ restish your-api your-operation-name --param=value ...
```

## Handler Function

The operation handler function always has the following generic format, where `Input` and `Output` are custom structs defined by the developer that represent the entirety of the request (path/query/header params & body) and response (headers & body), respectively:

```go
```go title="code.go"
func(context.Context, *Input) (*Output, error)
```

There are many options available for configuring OpenAPI settings for the operation, and custom extensions are supported as well. See the [`huma.Operation`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Schema) struct for more details.

!!! info "Naming"

Did you know? The `OperationID` is used to generate friendly CLI commands in [Restish](https://rest.sh/) and used when generating SDKs! It should be unique, descriptive, and easy to type.

## Input & Output Models

Inputs and outputs are **always** structs that represent the entirety of the incoming request or outgoing response. This is a deliberate design decision to make it easier to reason about the data flow in your application. It also makes it easier to share code as well as generate documentation and SDKs.

If your operation has no inputs or outputs, you can use `*struct{}` when registering it.
If your operation has no inputs or outputs, you can use a pointer to an empty struct `*struct{}` when registering it.
```go title="code.go"
func(ctx context.Context, input *struct{}) (*struct{}, error) {
// Successful response example, defaults to HTTP 204 No Content
return nil, nil
}
```

## Request Flow

A request flowing into the API goes through a number of steps before reaching your operation handler. The following diagram shows the flow of a request through the system, from request inputs like path/query/header parameters and the request body, through validation, the operation handler, and how outputs are sent in the response.

```mermaid
graph LR
subgraph Inputs
Path
Query
Header
Body
RawBody
end
subgraph Outputs
Status
Headers
OutBody[Body]
end
Path --> Validate
Query --> Validate
Header --> Validate
Body --> Unmarshal --> Validate
Validate --> Resolve --> Operation
RawBody -->|raw body input| Operation
Operation --> Transform
Transform --> Status
Transform --> Headers
Transform --> Marshal --> OutBody
Operation -->|raw body output| OutBody
style Operation stroke:#f9f,stroke-width:2px,stroke-dasharray: 5 5
```

`Unmarshal`

: Read the raw bytes of the request body (e.g. JSON) into a Go structure.

[`Validate`](./request-validation.md)

: Check constraints on the inputs (e.g. `minimum`, `maxLength`, etc) and report failures.

[`Resolve`](./request-resolvers.md)

: Run custom validation code and report failures.

`Operation`

: Your operation handler function. Business logic goes here. It returns either your response structure or an error.

[`Transform`](./response-transformers.md)

: Modify the structured response data on the fly before marshaling it to bytes.

[`Marshal`](./response-serialization.md)

: Convert the structured response data into bytes (e.g. JSON).

Read on to learn about how each of these steps works.

## Dive Deeper

Expand Down
153 changes: 153 additions & 0 deletions docs/docs/features/response-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Response Errors

## Returning HTTP Errors

Handler functions can return errors instead of a successful response. There are a number of utility functions to return common HTTP errors:

```go title="code.go" hl_lines="7-8"
huma.Register(api, huma.Operation{
OperationID: "get-thing",
Method: http.MethodGet,
Path: "/things/{thing-id}",
Summary: "Get a thing by ID",
}, func(ctx context.Context, input ThingRequest) (*struct{}, error) {
// Return a 404 Not Found error
return nil, huma.Error404NotFound("thing not found")
}
```

The error functions are named like `Error{code}{name}` and accept a message and error details which can provide more information back to the user. For example, `huma.Error400BadRequest(msg string, errs ...error)`. Editors like VSCode should automatically show the available errors as you type:

![VSCode errors](vscode-errors.png)

!!! warning "Default Error Response"

If the error returned has no associated HTTP status code, for example you use `fmt.Errorf("my error")`, then the default error response code is `500 Internal Server Error`. Use [`huma.NewError`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#NewError) to return an error with a custom status code.

## Error Model

Errors use [RFC 7807 Problem Details for HTTP APIs](https://tools.ietf.org/html/rfc7807) with a content type like `application/problem+json` and return a structure that looks like:

```http title="HTTP Response"
HTTP/2.0 422 Unprocessable Entity
Cache-Control: private
Content-Length: 241
Content-Type: application/problem+json
Link: </schemas/ErrorModel.json>; rel="describedBy"
{
"$schema": "https://api.rest.sh/schemas/ErrorModel.json",
"status": 422,
"title": "Unprocessable Entity",
"detail": "validation failed",
"errors": [
{
"location": "body.title",
"message": "expected string",
"value": true
},
{
"location": "body.reviews",
"message": "unexpected property",
"value": {
"reviews": 5,
"title": true
}
}
]
}
```

The `errors` field is optional and may contain more details about which specific errors occurred. See [`huma.ErrorModel`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ErrorModel) for more details.

To display a `location`, `message`, and `value` in the errors array, use the [`huma.ErrorDetail`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ErrorDetail) struct. If you need to wrap this with custom logic for any reason, you can implement the [`huma.ErrorDetailer`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ErrorDetailer) interface.

### Exhaustive Errors

It is recommended to return exhaustive errors whenever possible to prevent user frustration with having to keep retrying a bad request and getting back a different error.

Input parameters validation, body validation, resolvers, etc all support returning exhaustive errors. Because of this, it's preferable to use them over custom error logic in your operation handler.

## Error Status Codes

While every attempt is made to return exhaustive errors within Huma, each individual response can only contain a single HTTP status code. The following chart describes which codes get returned and when:

```mermaid
flowchart TD
Request[Request has errors?] -->|yes| Panic
Request -->|no| Continue[Continue to handler]
Panic[Panic?] -->|yes| 500
Panic -->|no| RequestBody[Request body too large?]
RequestBody -->|yes| 413
RequestBody -->|no| RequestTimeout[Request took too long to read?]
RequestTimeout -->|yes| 408
RequestTimeout -->|no| ParseFailure[Cannot parse input?]
ParseFailure -->|yes| 400
ParseFailure -->|no| ValidationFailure[Validation failed?]
ValidationFailure -->|yes| 422
ValidationFailure -->|no| 400
```

This means it is possible to, for example, get an HTTP `408 Request Timeout` response that _also_ contains an error detail with a validation error for one of the input headers. Since request timeout has higher priority, that will be the response status code that is returned.

## Custom Errors

It is possible to provide your own error model and have the built-in error utility functions use that model instead of the default one. This is useful if you want to provide more information in your error responses or your organization has requirements around the error response structure.

This is accmplished by defining your custom model as a [`huma.StatusError`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#StatusError) and then overriding the built-in [`huma.NewError`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#NewError) function:

```go title="code.go" hl_lines="1-13 16-26 36"
type MyError struct {
status int
Message string `json:"message"`
Details []string `json:"details,omitempty"`
}

func (e *MyError) Error() string {
return e.Message
}

func (e *MyError) GetStatus() int {
return e.status
}

func main() {
huma.NewError = func(status int, message string, errs ...error) huma.StatusError {
details := make([]string, len(errs))
for i, err := range errs {
details[i] = err.Error()
}
return &MyError{
status: status,
Message: message,
Details: details,
}
}

router := chi.NewMux()
api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))

huma.Register(api, huma.Operation{
OperationID: "get-error",
Method: http.MethodGet,
Path: "/error",
}, func(ctx context.Context, i *struct{}) (*struct{}, error) {
return nil, huma.Error404NotFound("not found", fmt.Errorf("some-other-error"))
})

http.ListenAndServe(":8888", router)
}
```

To change the default content type that is returned, you can also implement the [`huma.ContentTypeFilter`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ContentTypeFilter) interface.

## Dive Deeper

- Reference
- [`huma.ErrorModel`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ErrorModel) the default error model
- [`huma.ErrorDetail`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ErrorDetail) describes location & value of an error
- [`huma.StatusError`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#StatusError) interface for custom errors
- [`huma.ContentTypeFilter`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ContentTypeFilter) interface for custom content types
- External Links
- [HTTP Status Codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status)
- [RFC 7807](https://tools.ietf.org/html/rfc7807) Problem Details for HTTP APIs
Loading

0 comments on commit 23c78a4

Please sign in to comment.