danielgtaylor / huma

Huma REST/HTTP API Framework for Golang with OpenAPI 3.1
https://huma.rocks/
MIT License
2.17k stars 152 forks source link

How to separate internal and external API routes? #588

Open cplaetzinger opened 1 month ago

cplaetzinger commented 1 month ago

Hi there,

is there a way to generate two different versions of the OpenAPI specification and distinguish between internal and external routes? Our idea is that we want only specific routes to be included in the documentation we provide to some of our clients. Some other routes should not appear there at all because they are only for internal use by us. Any ideas on how this can be archived?

Many thanks Christian

superstas commented 1 month ago

Hey @cplaetzinger

I think, you can achieve it by setting Hidden: true for each Operation you want to skip in the generated OAS.

https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Operation

    // Hidden will skip documenting this operation in the OpenAPI. This is
    // useful for operations that are not intended to be used by clients but
    // you'd still like the benefits of using Huma. Generally not recommended.
cplaetzinger commented 1 month ago

Hey @cplaetzinger

I think, you can achieve it by setting Hidden: true for each Operation you want to skip in the generated OAS.

https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Operation

  // Hidden will skip documenting this operation in the OpenAPI. This is
  // useful for operations that are not intended to be used by clients but
  // you'd still like the benefits of using Huma. Generally not recommended.

Thanks for your input. We're aware of this flag but as it will hide these endpoints completely. We want to generate two api documentations which are based on the same source but contain different endpoints.

superstas commented 1 month ago

@cplaetzinger, if I understand your idea, this is probably one way to have the only source and two different specs.

Pseudo API with four endpoints. You can control which of the operations would be skipped in which specs.

In this example, there are:

The diff between yaml files:

5a6,12
>         $schema:
>           description: A URL to the JSON Schema for this object.
>           examples:
>             - https://example.com/schemas/CreateUserOutputBody.json
>           format: uri
>           readOnly: true
>           type: string
13a21,27
>         $schema:
>           description: A URL to the JSON Schema for this object.
>           examples:
>             - https://example.com/schemas/DeleteUserOutputBody.json
>           format: uri
>           readOnly: true
>           type: string
109c123
<   title: Public API
---
>   title: Private API
112a127,142
>   /user:
>     post:
>       operationId: CreateUser
>       responses:
>         "200":
>           content:
>             application/json:
>               schema:
>                 $ref: "#/components/schemas/CreateUserOutputBody"
>           description: OK
>         default:
>           content:
>             application/problem+json:
>               schema:
>                 $ref: "#/components/schemas/ErrorModel"
>           description: Error
113a144,164
>     delete:
>       operationId: DeleteUser
>       parameters:
>         - in: path
>           name: id
>           required: true
>           schema:
>             type: string
>       responses:
>         "200":
>           content:
>             application/json:
>               schema:
>                 $ref: "#/components/schemas/DeleteUserOutputBody"
>           description: OK
>         default:
>           content:
>             application/problem+json:
>               schema:
>                 $ref: "#/components/schemas/ErrorModel"
>           description: Error

Example

package main

import (
    "context"
    "net/http"
    "os"

    "github.com/danielgtaylor/huma/v2"
    "github.com/danielgtaylor/huma/v2/adapters/humachi"
    "github.com/go-chi/chi/v5"
)

type CreateUserInput struct{}
type CreateUserOutput struct {
    Body struct {
        Message string `json:"message"`
    }
}

type GetUserInput struct {
    ID string `path:"id" required:"true"`
}

type GetUserOutput struct {
    Body struct {
        Message string `json:"message"`
    }
}

type UpdateUserInput struct {
    ID string `path:"id" required:"true"`
}

type UpdateUserOutput struct {
    Body struct {
        Message string `json:"message"`
    }
}

type DeleteUserInput struct {
    ID string `path:"id" required:"true"`
}

type DeleteUserOutput struct {
    Body struct {
        Message string `json:"message"`
    }
}

// Function to add routes with the ability to hide specific operations
func addRoutes(api huma.API, hiddenOperations []string) {
    isHidden := func(operationID string) bool {
        for _, id := range hiddenOperations {
            if id == operationID {
                return true
            }
        }
        return false
    }

    // CreateUser route
    huma.Register(api, huma.Operation{
        OperationID: "CreateUser",
        Method:      http.MethodPost,
        Path:        "/user",
        Hidden:      isHidden("CreateUser"),
    }, func(ctx context.Context, input *CreateUserInput) (*CreateUserOutput, error) {
        resp := &CreateUserOutput{}
        resp.Body.Message = "CreateUser works!"
        return resp, nil
    })

    // GetUser route
    huma.Register(api, huma.Operation{
        OperationID: "GetUser",
        Method:      http.MethodGet,
        Path:        "/user/{id}",
        Hidden:      isHidden("GetUser"),
    }, func(ctx context.Context, input *GetUserInput) (*GetUserOutput, error) {
        resp := &GetUserOutput{}
        resp.Body.Message = "GetUser with ID: " + input.ID + " works!"
        return resp, nil
    })

    // UpdateUser route
    huma.Register(api, huma.Operation{
        OperationID: "UpdateUser",
        Method:      http.MethodPut,
        Path:        "/user/{id}",
        Hidden:      isHidden("UpdateUser"),
    }, func(ctx context.Context, input *UpdateUserInput) (*UpdateUserOutput, error) {
        resp := &UpdateUserOutput{}
        resp.Body.Message = "UpdateUser with ID: " + input.ID + " works!"
        return resp, nil
    })

    // DeleteUser route
    huma.Register(api, huma.Operation{
        OperationID: "DeleteUser",
        Method:      http.MethodDelete,
        Path:        "/user/{id}",
        Hidden:      isHidden("DeleteUser"),
    }, func(ctx context.Context, input *DeleteUserInput) (*DeleteUserOutput, error) {
        resp := &DeleteUserOutput{}
        resp.Body.Message = "DeleteUser with ID: " + input.ID + " works!"
        return resp, nil
    })
}

func newAPI(name, version string, hiddenOperations []string) huma.API {
    router := chi.NewMux()
    cfg := huma.DefaultConfig(name, version)
    api := humachi.New(router, cfg)
    addRoutes(api, hiddenOperations)
    return api
}

func saveAPI(api huma.API, filename string) {
    spec, err := api.OpenAPI().YAML()
    if err != nil {
        panic(err)
    }

    if err := os.WriteFile(filename, spec, 0644); err != nil {
        panic(err)
    }
}

func main() {
    publicAPI := newAPI("Public API", "1.0.0", []string{"DeleteUser", "CreateUser"})
    privateAPI := newAPI("Private API", "1.0.0", []string{})

    saveAPI(publicAPI, "public-api.yaml")
    saveAPI(privateAPI, "private-api.yaml")
}

I hope that makes sense for you.

cplaetzinger commented 1 month ago

Many thanks! I'll try that.

danielgtaylor commented 1 month ago

@cplaetzinger let me know if that works for you. @superstas thanks for the help! BTW in the past I also used something like https://github.com/danielgtaylor/apiscrub and just added some extensions into the OpenAPI to mark which operations were private, then had a separate step to publish both OpenAPI documents. I do like the idea of doing it all in-process and with Huma though!

BTW this is a common enough ask I've gotten that I'm open to ideas for how to make this easier.

cardinalby commented 1 week ago

I believe we should make it possible without modifying the client code that registers the operations but redirecting the registration calls to the separate instances of OpenAPI instead depending on provided "api" instance.

Having "derived groups" concept, we can provide different api objects to register "internal" and "external" endpoint without changes in endpoint definitions. This way we can route registration to a separate OpenAPI instances and expose separate "spec" and "schema" endpoints.