biocad / servant-openapi3

OpenAPI 3.0 for Servant
BSD 3-Clause "New" or "Revised" License
40 stars 26 forks source link

How to add route-based info using servant API types? #6

Open tfc opened 3 years ago

tfc commented 3 years ago

hi there,

regarding the example https://github.com/biocad/servant-openapi3/blob/master/example/src/Todo.hs

i have an API, that looks like this, if you adapt the example code:

type TodoAPI = GetTodoList :<|> PostNewTodo :<|> GetTodo

type GetTodoList = "list" :> Get '[JSON] [Todo]
type PostNewTodo =  "create" :> ReqBody '[JSON] Todo :> Post '[JSON] TodoId
type GetTodo = "todo" :> Capture "id" TodoId :> Get '[JSON] Todo

can i now describe those 3 functions in my openapi just using the types GetTodoList, PostNewTodo, and GetTodo?

i am looking for something like (function f would be just what i need, but it's just a rough example):

todoSwagger = toOpenApi todoAPI
  & info.title   .~ "Todo API"
  & info.version .~ "1.0"
  & info.description ?~ "This is an API that tests swagger integration"
  & info.license ?~ ("MIT" & url ?~ URL "http://mit.com")

  & f (Proxy :: GetTodoList) [...description, response info, etc. etc....]
  & f (Proxy :: PostNewTodo) [...description, response info, etc. etc....]
  & f (Proxy :: GetTodo)     [...description, response info, etc. etc....]

there are examples which do similar things in the openapi3 docs which access the paths via & paths .~ [("/some-path", ...description....)], but this would mean that i would need to duplicate the path information from the types. That would be unelegant as i would no longer have all paths defined in my central API definition.

Is there a way? I am a lens newbie, so i might not have discovered the obvious way if there is one.

maksbotan commented 3 years ago

Hi!

What information do you want to add to your API?

Generally, the method to do this is to introduce your own combinator, say WithSomeInfo. And then define an instance:

instance HasOpenApi api => HasOpenApi (WithSomeInfo :> api) where
  toOpenApi _ = toOpenApi @api Proxy
    & description ?~ "Something" -- for example

Actually, some combinators from Servant, like Summary and Description, already have such instances. You can see a list in the haddocks: https://hackage.haskell.org/package/servant-openapi3-2.0.1.1/docs/Servant-OpenApi.html#t:HasOpenApi

tfc commented 3 years ago

Right... Summary and Description are perfect, not only for generating the openapi stuff but also servant docs - very useful and much more straightforward than the approach that i asked for, thank you!

Now i am experimenting with UVerbs to describe how the api reacts to success/failure (and what kind of failure), but that does not appear to be trivial and i am not sure if that is a servant- or a servant-openapi problem. How would you build an api path whose servant-openapi gives a description for the 200, 404, xxx http status codes and bodies that it could have?

maksbotan commented 3 years ago

Basically, you replace Get or Post with this:

type Api = UVerb 'GET '[JSON] '[ResOK, ResError]

data ResOK = ...
  deriving HasStatus via (WithStatus 200 ResOK)

data ResOK = ...
  deriving HasStatus via (WithStatus 400 ResError)

I personally like this style, but alternatively you can do UVerb 'GET '[JSON] '[WithStatus 200 ResOK, WithStatus 400 ResError].

Note that currently UVerb requires that status codes of variants are unique. servant-openapi3 will automatically support such apis. You can see more in upstream servant and servant-server docs.

Also, it is possible to make combinators injecting results to whole sub-apis, in style of WithSomeInfo I've shown above, for example to add common 401 responses in auth handler. Take a look at addResponse or smth like that in servant-openapi3 internal modules, I can't remember the exact name at the moment.

tfc commented 3 years ago

Hi,

i see where your suggestions are going, but i was not successful in getting them to run, yet.

let me show you where i am with an example:

{-# LANGUAGE DataKinds         #-}
{-# LANGUAGE DeriveGeneric     #-}
{-# LANGUAGE DerivingVia       #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeOperators     #-}
{-# LANGUAGE UndecidableInstances     #-}

module MyApi where

import           Data.Aeson
import           Data.Aeson.Casing
import           Data.OpenApi
import           Data.Proxy        (Proxy (..))
import           Data.Text         (Text)
import qualified Data.Text         as Text
import           GHC.Generics
import           Servant.API
import           Servant.Docs

-- for openapi things
import           Control.Lens
import           Servant.OpenApi

-- these imports are for the `Foo` thing
import           Data.Typeable (Typeable)
import           GHC.TypeLits  (KnownSymbol, Symbol, symbolVal)

-- for pretty printing the api
import           Data.Aeson.Encode.Pretty
import qualified Data.ByteString.Lazy.Char8 as BSL

data Thing = Thing
    { thingName        :: String
    , thingColor       :: String
    , thingWeightGrams :: Int
    } deriving (Eq, Show, Generic)

instance ToJSON Thing where
   toJSON = genericToJSON $ aesonPrefix snakeCase
instance FromJSON Thing where
   parseJSON = genericParseJSON $ aesonPrefix snakeCase

instance ToSample Thing where
    toSamples _ = singleSample $ Thing "shoe" "brown" 500

type ThingApi = ThingCreate

type ThingCreate = Summary "Create a thing"
                :> "create"
                :> ReqBody '[JSON] Thing
                :> PostNoContent
                -- :> UVerb 'POST '[JSON] '[WithStatus 200 ResOK, WithStatus 400 ResError]

data ResOK = ResOK String
    deriving Generic

data ResError = ResError String
  deriving Generic

thingApi :: Proxy ThingApi
thingApi = Proxy

instance ToSchema Thing where
  declareNamedSchema proxy = genericDeclareNamedSchema defaultSchemaOptions proxy
    & mapped.schema.description ?~ "Description of a thing"

instance ToSchema ResOK where
  declareNamedSchema proxy = genericDeclareNamedSchema defaultSchemaOptions proxy
    & mapped.schema.description ?~ "Success Response"

instance ToSchema ResError where
  declareNamedSchema proxy = genericDeclareNamedSchema defaultSchemaOptions proxy
    & mapped.schema.description ?~ "Error Response"

thingOpenApi :: OpenApi
thingOpenApi = toOpenApi thingApi
  & info.title   .~ "Thing API"
  & info.version .~ "1.0"
  & info.description ?~ "This API is about things"

main = BSL.putStrLn $ encodePretty thingOpenApi

looking at the openapi output, this gives me this:

...
    "paths": {
        "/create": {
            "post": {
                "summary": "Create a thing",
                "responses": {
                    "400": {
                        "description": "Invalid `body`"
                    },
                    "204": {
                        "description": ""
                    }
                },
...

this is great already, but it's still desirable to add text to the description field of the empty 204 response, as described in the openapi3 docs: https://swagger.io/docs/specification/describing-responses/#empty, e.g. "thing was created". Also, it would be nice to have a different error description than "invalid body".

so i understand that to get both, i can simply use UVerb to "overload" those http codes like this, following your advise:

:> Uverb 'POST [WithStatus 200 ResOK, WithStatus 400 ResError]

(somehow i don't understand how to combine this with NoContent semantics)

this way the compiler wants ToSchema instances of ResOK and ResError, where i either end up having a content field which has its description in the schema description at the other part of the generated openapi document, or it just doesn't compile (e.g. description ?~ "description" is not accepted and i don't understand from reading the code what the right way is).

It would kind of help me best if there was something like:

:> Uverb 'POST [
  WithStatus 204 (Description "Thing was created" NoContent),
  WithStatus 403 (Description "Such un-thing-like things are simply not allowed" NoContent),
  WithStatus 418 (Description "Turns out i am just a teapot. Sorry." NoContent)
]

which then generates an openapi spec like this:

...
    "paths": {
        "/create": {
            "post": {
                "summary": "Create a thing",
                "responses": {
                    "403": {
                        "description": "Such un-thing-like things are simply not allowed"
                    },
                    "418": {
                       "description": "Turns out i am just a teapot. Sorry."
                    },
                    "204": {
                        "description": "Thing was created"
                    }
                },
...

isn't that possible somehow?

tfc commented 3 years ago

@maksbotan i continued experimenting... so far i came up with the following:

type ThingCreate = Summary "Create a thing"
                :> "create"
                :> ReqBody '[JSON] Thing
                :> UVerb 'POST '[] '[
                         WithStatus 200 (NoContentDescribed "item creation successful"),
                         WithStatus 300 (NoContentDescribed "something something"),
                    ]

data NoContentDescribed (desc :: Symbol)

instance (AllAccept cs, KnownNat status, OpenApiMethod method, KnownSymbol desc)
  => HasOpenApi (Verb method status cs (NoContentDescribed desc)) where
    toOpenApi _ = toOpenApi (Proxy :: Proxy (Verb method status cs (Headers '[] NoContent)))
    -- idea is to "attach" description after passing this through to normal nocontent answers which contain empty description
     & description .~ (something with desc)

Here i run into the problem, that servant-openapi3/src/Servant/OpenApi/Internal.hs defines the HasOpenApi instance for the UVerb type, but adds ToSchema a for the items in the list, which is bad in this case because NoContent is meant to have no schema. so i would not even be able to use UVerb 'POST '[] '[NoContent] alone.

Am i trying something that is completely unnatural (adding description to empty responses without a schema), or would this be done completely differently?