License | BSD3 |
---|---|
Maintainer | Nickolay Kudasov <nickolay@getshoptv.com> |
Stability | experimental |
Safe Haskell | Safe-Inferred |
Language | Haskell2010 |
This module provides means to generate and manipulate OpenApi specification for servant APIs.
OpenApi is a project used to describe and document RESTful APIs.
The OpenApi specification defines a set of files required to describe such an API. These files can then be used by the OpenApi-UI project to display the API and OpenApi-Codegen to generate clients in various languages. Additional utilities can also take advantage of the resulting files, such as testing tools.
For more information see OpenApi documentation.
Synopsis
- class HasOpenApi api where
- subOperations :: (IsSubAPI sub api, HasOpenApi sub) => Proxy sub -> Proxy api -> Traversal' OpenApi Operation
- validateEveryToJSON :: forall proxy api. TMap (Every [Typeable, Show, Arbitrary, ToJSON, ToSchema]) (BodyTypes JSON api) => proxy api -> Spec
- validateEveryToJSONWithPatternChecker :: forall proxy api. TMap (Every [Typeable, Show, Arbitrary, ToJSON, ToSchema]) (BodyTypes JSON api) => (Pattern -> Text -> Bool) -> proxy api -> Spec
How to use this library
This section explains how to use this library to generate OpenApi specification, modify it and run automatic tests for a servant API.
For the purposes of this section we will use this servant API:
>>>
data User = User { name :: String, age :: Int } deriving (Show, Generic, Typeable)
>>>
newtype UserId = UserId Integer deriving (Show, Generic, Typeable, ToJSON)
>>>
instance ToJSON User
>>>
instance ToSchema User
>>>
instance ToSchema UserId
>>>
instance ToParamSchema UserId
>>>
type GetUsers = Get '[JSON] [User]
>>>
type GetUser = Capture "user_id" UserId :> Get '[JSON] User
>>>
type PostUser = ReqBody '[JSON] User :> Post '[JSON] UserId
>>>
type UserAPI = GetUsers :<|> GetUser :<|> PostUser
Here we define a user API with three endpoints. GetUsers
endpoint returns a list of all users.
GetUser
returns a user given his/her ID. PostUser
creates a new user and returns his/her ID.
Generate OpenApi
OpenApi
In order to generate
specification for a servant API, just use OpenApi
:toOpenApi
>>>
BSL8.putStrLn $ encodePretty $ toOpenApi (Proxy :: Proxy UserAPI)
{ "components": { "schemas": { "User": { "properties": { "age": { "maximum": 9223372036854775807, "minimum": -9223372036854775808, "type": "integer" }, "name": { "type": "string" } }, "required": [ "name", "age" ], "type": "object" }, "UserId": { "type": "integer" } } }, "info": { "title": "", "version": "" }, "openapi": "3.0.0", "paths": { "/": { "get": { "responses": { "200": { "content": { "application/json;charset=utf-8": { "schema": { "items": { "$ref": "#/components/schemas/User" }, "type": "array" } } }, "description": "" } } }, "post": { "requestBody": { "content": { "application/json;charset=utf-8": { "schema": { "$ref": "#/components/schemas/User" } } }, "required": true }, "responses": { "200": { "content": { "application/json;charset=utf-8": { "schema": { "$ref": "#/components/schemas/UserId" } } }, "description": "" }, "400": { "description": "Invalid `body`" } } } }, "/{user_id}": { "get": { "parameters": [ { "in": "path", "name": "user_id", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "content": { "application/json;charset=utf-8": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "" }, "404": { "description": "`user_id` not found" } } } } } }
By default
will generate specification for all API routes, parameters, headers, responses and data schemas.toOpenApi
For some parameters it will also add 400 and/or 404 responses with a description mentioning parameter name.
Data schemas come from
and ToParamSchema
classes.ToSchema
Annotate
While initially generated
looks good, it lacks some information it can't get from a servant API.OpenApi
We can add this information using field lenses from Data.OpenApi
:
>>>
:{
BSL8.putStrLn $ encodePretty $ toOpenApi (Proxy :: Proxy UserAPI) & info.title .~ "User API" & info.version .~ "1.0" & info.description ?~ "This is an API for the Users service" & info.license ?~ "MIT" & servers .~ ["https://example.com"] :} { "components": { "schemas": { "User": { "properties": { "age": { "maximum": 9223372036854775807, "minimum": -9223372036854775808, "type": "integer" }, "name": { "type": "string" } }, "required": [ "name", "age" ], "type": "object" }, "UserId": { "type": "integer" } } }, "info": { "description": "This is an API for the Users service", "license": { "name": "MIT" }, "title": "User API", "version": "1.0" }, "openapi": "3.0.0", "paths": { "/": { "get": { "responses": { "200": { "content": { "application/json;charset=utf-8": { "schema": { "items": { "$ref": "#/components/schemas/User" }, "type": "array" } } }, "description": "" } } }, "post": { "requestBody": { "content": { "application/json;charset=utf-8": { "schema": { "$ref": "#/components/schemas/User" } } }, "required": true }, "responses": { "200": { "content": { "application/json;charset=utf-8": { "schema": { "$ref": "#/components/schemas/UserId" } } }, "description": "" }, "400": { "description": "Invalid `body`" } } } }, "/{user_id}": { "get": { "parameters": [ { "in": "path", "name": "user_id", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "content": { "application/json;charset=utf-8": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "" }, "404": { "description": "`user_id` not found" } } } } }, "servers": [ { "url": "https://example.com" } ] }
It is also useful to annotate or modify certain endpoints.
provides a convenient way to zoom into a part of an API.subOperations
traverses all operations of the subOperations
sub apiapi
which are also present in sub
.
Furthermore, sub
is required to be an exact sub API of @api. Otherwise it will not typecheck.
Data.OpenApi.Operation
provides some useful helpers that can be used with
.
One example is applying tags to certain endpoints:subOperations
>>>
let getOps = subOperations (Proxy :: Proxy (GetUsers :<|> GetUser)) (Proxy :: Proxy UserAPI)
>>>
let postOps = subOperations (Proxy :: Proxy PostUser) (Proxy :: Proxy UserAPI)
>>>
:{
BSL8.putStrLn $ encodePretty $ toOpenApi (Proxy :: Proxy UserAPI) & applyTagsFor getOps ["get" & description ?~ "GET operations"] & applyTagsFor postOps ["post" & description ?~ "POST operations"] :} { "components": { "schemas": { "User": { "properties": { "age": { "maximum": 9223372036854775807, "minimum": -9223372036854775808, "type": "integer" }, "name": { "type": "string" } }, "required": [ "name", "age" ], "type": "object" }, "UserId": { "type": "integer" } } }, "info": { "title": "", "version": "" }, "openapi": "3.0.0", "paths": { "/": { "get": { "responses": { "200": { "content": { "application/json;charset=utf-8": { "schema": { "items": { "$ref": "#/components/schemas/User" }, "type": "array" } } }, "description": "" } }, "tags": [ "get" ] }, "post": { "requestBody": { "content": { "application/json;charset=utf-8": { "schema": { "$ref": "#/components/schemas/User" } } }, "required": true }, "responses": { "200": { "content": { "application/json;charset=utf-8": { "schema": { "$ref": "#/components/schemas/UserId" } } }, "description": "" }, "400": { "description": "Invalid `body`" } }, "tags": [ "post" ] } }, "/{user_id}": { "get": { "parameters": [ { "in": "path", "name": "user_id", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "content": { "application/json;charset=utf-8": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "" }, "404": { "description": "`user_id` not found" } }, "tags": [ "get" ] } } }, "tags": [ { "description": "GET operations", "name": "get" }, { "description": "POST operations", "name": "post" } ] }
This applies "get"
tag to the GET
endpoints and "post"
tag to the POST
endpoint of the User API.
Test
Automatic generation of data schemas uses
instances for the types
used in a servant API. But to encode/decode actual data servant uses different classes.
For instance in ToSchema
UserAPI
User
is always encoded/decoded using
and ToJSON
instances.FromJSON
To be sure your Haskell server/client handles data properly you need to check
that
instance always generates values that satisfy schema produced
by ToJSON
instance.ToSchema
With
it is possible to test all those instances automatically,
without having to write down every type:validateEveryToJSON
>>>
instance Arbitrary User where arbitrary = User <$> arbitrary <*> arbitrary
>>>
instance Arbitrary UserId where arbitrary = UserId <$> arbitrary
>>>
hspec $ validateEveryToJSON (Proxy :: Proxy UserAPI)
[User]... ... User... ... UserId... ... Finished in ... seconds 3 examples, 0 failures
Although servant is great, chances are that your API clients don't use Haskell.
In many cases swagger.json
serves as a specification, not a Haskell type.
In this cases it is a good idea to store generated and annotated
in a OpenApi
swagger.json
file
under a version control system (such as Git, Subversion, Mercurial, etc.).
It is also recommended to version API based on changes to the swagger.json
rather than changes
to the Haskell API.
See TodoSpec.hs for an example of a complete test suite for a swagger specification.
Serve
If you're implementing a server for an API, you might also want to serve its
specification.OpenApi
See Todo.hs for an example of a server.
HasOpenApi
class
HasOpenApi
class HasOpenApi api where Source #
Generate a OpenApi specification for a servant API.
To generate OpenApi specification, your data types need
and/or ToParamSchema
instances.ToSchema
is used for ToParamSchema
, Capture
and QueryParam
.
ResponseHeader
is used for ToSchema
and response data types.ReqBody
You can easily derive those instances via Generic
.
For more information, refer to
openapi3 documentation.
Example:
newtype Username = Username String deriving (Generic, ToText) instance ToParamSchema Username data User = User { username :: Username , fullname :: String } deriving (Generic) instance ToJSON User instance ToSchema User type MyAPI = QueryParam "username" Username :> Get '[JSON] User myOpenApi :: OpenApi myOpenApi = toOpenApi (Proxy :: Proxy MyAPI)
Instances
Manipulation
:: (IsSubAPI sub api, HasOpenApi sub) | |
=> Proxy sub | Part of a servant API. |
-> Proxy api | The whole servant API. |
-> Traversal' OpenApi Operation |
All operations of sub API.
This is similar to
but ensures that operations
indeed belong to the API at compile time.operationsOf
Testing
:: forall proxy api. TMap (Every [Typeable, Show, Arbitrary, ToJSON, ToSchema]) (BodyTypes JSON api) | |
=> proxy api | Servant API. |
-> Spec |
Verify that every type used with
content type in a servant API
has compatible JSON
and ToJSON
instances using ToSchema
.validateToJSON
NOTE:
does not perform string pattern validation.
See validateEveryToJSON
.validateEveryToJSONWithPatternChecker
will produce one validateEveryToJSON
specification for every type in the API.
Each type only gets one test, even if it occurs multiple times in the API.prop
>>>
data User = User { name :: String, age :: Maybe Int } deriving (Show, Generic, Typeable)
>>>
newtype UserId = UserId String deriving (Show, Generic, Typeable, ToJSON, Arbitrary)
>>>
instance ToJSON User
>>>
instance ToSchema User
>>>
instance ToSchema UserId
>>>
instance Arbitrary User where arbitrary = User <$> arbitrary <*> arbitrary
>>>
type UserAPI = (Capture "user_id" UserId :> Get '[JSON] User) :<|> (ReqBody '[JSON] User :> Post '[JSON] UserId)
>>>
hspec $ context "ToJSON matches ToSchema" $ validateEveryToJSON (Proxy :: Proxy UserAPI)
ToJSON matches ToSchema User... ... UserId... ... Finished in ... seconds 2 examples, 0 failures
For the test to compile all body types should have the following instances:
andToJSON
are used to perform the validation;ToSchema
is used to name the test for each type;Typeable
is used to display value for whichShow
does not satisfyToJSON
.ToSchema
is used to arbitrarily generate values.Arbitrary
If any of the instances is missing, you'll get a descriptive type error:
>>>
data Contact = Contact { fullname :: String, phone :: Integer } deriving (Show, Generic)
>>>
instance ToJSON Contact
>>>
instance ToSchema Contact
>>>
type ContactAPI = Get '[JSON] Contact
>>>
hspec $ validateEveryToJSON (Proxy :: Proxy ContactAPI)
... ...No instance for ...Arbitrary Contact... ... arising from a use of ‘validateEveryToJSON’ ...
validateEveryToJSONWithPatternChecker Source #
:: forall proxy api. TMap (Every [Typeable, Show, Arbitrary, ToJSON, ToSchema]) (BodyTypes JSON api) | |
=> (Pattern -> Text -> Bool) |
|
-> proxy api | Servant API. |
-> Spec |
Verify that every type used with
content type in a servant API
has compatible JSON
and ToJSON
instances using ToSchema
.validateToJSONWithPatternChecker
For validation without patterns see
.validateEveryToJSON