packages feed

minion-openapi3 (empty) → 0.1.0.0

raw patch · 6 files changed

+605/−0 lines, 6 filesdep +aesondep +basedep +blaze-html

Dependencies added: aeson, base, blaze-html, blaze-markup, bytestring, case-insensitive, directory, file-embed-lzma, filepath, http-api-data, http-types, insert-ordered-containers, lens, minion, minion-wai-extra, openapi3, template-haskell, text

Files

+ LICENSE view
@@ -0,0 +1,20 @@+Copyright (c) 2024 Danil Berestov++Permission is hereby granted, free of charge, to any person obtaining+a copy of this software and associated documentation files (the+"Software"), to deal in the Software without restriction, including+without limitation the rights to use, copy, modify, merge, publish,+distribute, sublicense, and/or sell copies of the Software, and to+permit persons to whom the Software is furnished to do so, subject to+the following conditions:++The above copyright notice and this permission notice shall be included+in all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ README.md view
@@ -0,0 +1,45 @@+# Minion++Minion is Haskell library for developing web applications. It stands between [Scotty](https://hackage.haskell.org/package/scotty) and [Servant](https://hackage.haskell.org/package/servant-server)  ++|                  | Scotty | Minion | Servant |+| ---------------- | ------ | ------ | ------- |+| As simple as ABC | Yes    | No     | No      |+| At term level    | Yes    | Yes    | No      |+| Typesafe         | No     | Yes    | Yes     |+| Introspectable   | No     | Yes    | Yes     |+| Generated client | No     | No     | Yes     |++  +Since Minion defines servers at the term level, it's easier to start and without excess verbosity.++```haskell+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE OverloadedLists #-}+module Main where++import Web.Minion+import Network.Wai.Handler.Warp qualified as Warp++main :: IO ()+main = Warp.run 9001 app++app :: ApplicationM IO+app = serve api ++api :: Router Void IO+api = "api" /> +    [ "about" /> handlePlainText @String GET (pure "Hello-World Minion server")+    , "hello" /> capture @String "name" +              .> handlePlainText @String GET (\name -> pure $ "Hello, " <> name <> "!")+    ]+```++Documentation and examples can be found on [Hackage](https://hackage.haskell.org/package/minion)  ++Minion ecosystem also contains following libraries:+* [minion-conduit](https://hackage.haskell.org/package/minion-conduit) +* [minion-htmx](https://hackage.haskell.org/package/minion-htmx) +* [minion-jwt](https://hackage.haskell.org/package/minion-jwt) +* [minion-wai-extra](https://hackage.haskell.org/package/minion-wai-extra) +* [minion-openapi3](https://hackage.haskell.org/package/minion-openapi3) 
+ minion-openapi3.cabal view
@@ -0,0 +1,80 @@+cabal-version:      3.0+name:               minion-openapi3+version:            0.1.0.0+license:            MIT+license-file:       LICENSE+author:             Danil Berestov+maintainer:         goosedb@yandex.ru+synopsis:           Minion openapi3 support+category:           Web+build-type:         Simple+extra-source-files: README.md++common common+  ghc-options:        -Wall+  default-extensions:+    AllowAmbiguousTypes+    BlockArguments+    ConstraintKinds+    DataKinds+    DefaultSignatures+    DeriveAnyClass+    DeriveGeneric+    DerivingStrategies+    DerivingStrategies+    DuplicateRecordFields+    DuplicateRecordFields+    FlexibleContexts+    FlexibleInstances+    FunctionalDependencies+    GADTs+    GeneralizedNewtypeDeriving+    ImportQualifiedPost+    LambdaCase+    MultiParamTypeClasses+    NamedFieldPuns+    OverloadedLists+    OverloadedRecordDot+    OverloadedRecordDot+    OverloadedStrings+    PolyKinds+    RankNTypes+    RecordWildCards+    RoleAnnotations+    ScopedTypeVariables+    TemplateHaskell+    TypeApplications+    TypeFamilies+    TypeOperators+    UndecidableInstances+    ViewPatterns++library+  import:           common+  exposed-modules:  Web.Minion.OpenApi3+  other-modules:+    Web.Minion.Embed+    Web.Minion.Files++  build-depends:+    , aeson+    , base >= 4.16 && < 5+    , blaze-html+    , blaze-markup+    , bytestring+    , case-insensitive+    , directory+    , file-embed-lzma+    , filepath+    , http-api-data+    , http-types+    , insert-ordered-containers+    , lens+    , minion+    , minion-wai-extra+    , openapi3+    , template-haskell+    , text++  hs-source-dirs:   src+  default-language: Haskell2010
+ src/Web/Minion/Embed.hs view
@@ -0,0 +1,24 @@+module Web.Minion.Embed where++import Control.Monad.IO.Class (MonadIO (..))+import FileEmbedLzma.Untyped+import Language.Haskell.TH+import System.Directory++indexName :: FilePath+indexName = "index.html.tmpl"++fixPath :: (MonadIO m) => FilePath -> m FilePath+fixPath path = liftIO do+  currDirectory <- getCurrentDirectory+  curDirectoryContents <- getDirectoryContents currDirectory+  pure+    if indexName `elem` curDirectoryContents+      then currDirectory <> "/" <> path+      else currDirectory <> "/minion-openapi3/" <> path++embedIndex :: Q Exp+embedIndex = fixPath indexName >>= embedText++embedUi :: Q Exp+embedUi = fixPath "ui" >>= embedRecursiveDir
+ src/Web/Minion/Files.hs view
@@ -0,0 +1,11 @@+module Web.Minion.Files where++import Data.ByteString qualified as Bytes+import Data.Text (Text)+import Web.Minion.Embed++indexTemplate :: Text+indexTemplate = $(embedIndex)++ui :: [(FilePath, Bytes.ByteString)]+ui = $(embedUi)
+ src/Web/Minion/OpenApi3.hs view
@@ -0,0 +1,425 @@+module Web.Minion.OpenApi3 (+  OpenApi3Config (..),+  OpenApi3,+  AttachRequestSchema (..),+  IsOpenApi3Description (..),+  OpenApi3Description (..),+  ToResponses (..),+  openapi3,+  -- | Use these newtypes to implement instances for according auths/response bodies/request bodies+  -- We do not implement it for concrete types to avoid extra dependencies+  AsCookieJwt (..),+  AsJwt (..),+  AsHtml (..),+  AsSSE (..),+  AsBinary (..),+  AsMultipart (..),+  AttachSecuritySchema (..),+  AttachSecuritySchemas (..),+) where++import Data.OpenApi hiding (Header (..))+import Web.Minion hiding (description)+import Web.Minion.Router++import Control.Applicative ((<|>))+import Control.Arrow ((>>>))+import Control.Lens hiding (index)+import Control.Monad.IO.Class (MonadIO)+import Data.Bifunctor (Bifunctor (..))+import Data.ByteString qualified as Bytes+import Data.CaseInsensitive qualified as CI+import Data.Data (Proxy (..))+import Data.Foldable (Foldable (..))+import Data.HashMap.Strict.InsOrd qualified as HM+import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap+import Data.Maybe (listToMaybe)+import Data.OpenApi.Declare (runDeclare)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Network.HTTP.Types (Status (..))+import Network.HTTP.Types qualified as Http+import Text.Blaze+import Text.Blaze.Html.Renderer.Utf8 (renderHtml)+import Web.HttpApiData (ToHttpApiData (..))+import Web.Minion.Auth.Basic (Basic)+import Web.Minion.Files (indexTemplate, ui)+import Web.Minion.Introspect qualified as I+import Web.Minion.Media+import Web.Minion.Response.Header qualified as Header+import Web.Minion.Response.Status+import Web.Minion.Response.Union+import Web.Minion.Static++data OpenApi3Config = OpenApi3Config+  { openapi3File :: !FilePath+  , staticDir :: !FilePath+  }++data OpenApi3++class AttachRequestSchema a where+  attachRequestSchema :: OpenApi -> OpenApi++type instance I.Introspection OpenApi3 I.QueryParam = ToParamSchema+type instance I.Introspection OpenApi3 I.Capture = ToParamSchema+type instance I.Introspection OpenApi3 I.Captures = ToParamSchema+type instance I.Introspection OpenApi3 I.Header = ToParamSchema+type instance I.Introspection OpenApi3 I.Request = AttachRequestSchema+type instance I.Introspection OpenApi3 I.Response = ToResponses+type instance I.Introspection OpenApi3 I.Description = IsOpenApi3Description++class IsOpenApi3Description a where+  toOpenApi3Description :: a -> OpenApi3Description++instance IsOpenApi3Description OpenApi3Description where+  toOpenApi3Description = id++data OpenApi3Description = DescriptionText Text | SummaryText Text++instance (AttachSecuritySchemas as) => AttachRequestSchema (Auth as a) where+  attachRequestSchema = attachSecuritySchemas @as++class AttachSecuritySchemas as where+  attachSecuritySchemas :: OpenApi -> OpenApi++instance AttachSecuritySchemas '[] where+  attachSecuritySchemas = id++instance (AttachSecuritySchema a, AttachSecuritySchemas as) => AttachSecuritySchemas (a ': as) where+  attachSecuritySchemas = attachSecuritySchemas @as . attachSecuritySchema @a++class AttachSecuritySchema a where+  attachSecuritySchema :: OpenApi -> OpenApi++instance AttachSecuritySchema Basic where+  attachSecuritySchema = addSecurityRequirement identifier . addSecurityScheme identifier securityScheme+   where+    identifier = "BasicAuth"+    securityScheme =+      SecurityScheme+        { _securitySchemeType = SecuritySchemeHttp HttpSchemeBasic+        , _securitySchemeDescription = Just "Basic Authentication"+        }++newtype AsJwt a = AsJwt a+instance AttachSecuritySchema (AsJwt a) where+  attachSecuritySchema = addSecurityRequirement identifier . addSecurityScheme identifier securityScheme+   where+    identifier = "JWT"+    securityScheme =+      SecurityScheme+        { _securitySchemeType = SecuritySchemeHttp $ HttpSchemeBearer $ Just "JWT"+        , _securitySchemeDescription = Just "Bearer Authentication"+        }++newtype AsCookieJwt a = AsCookieJwt a++instance AttachSecuritySchema (AsCookieJwt a) where+  attachSecuritySchema = addSecurityRequirement identifier . addSecurityScheme identifier securityScheme+   where+    identifier = "Cookie"+    securityScheme =+      SecurityScheme+        { _securitySchemeType = SecuritySchemeHttp $ HttpSchemeBearer $ Just "JWT"+        , _securitySchemeDescription = Just "Cookie Authentication"+        }++addSecurityScheme :: Text -> SecurityScheme -> OpenApi -> OpenApi+addSecurityScheme securityIdentifier securityScheme openApi =+  openApi+    { _openApiComponents =+        (_openApiComponents openApi)+          { _componentsSecuritySchemes =+              _componentsSecuritySchemes (_openApiComponents openApi)+                <> SecurityDefinitions (HM.singleton securityIdentifier securityScheme)+          }+    }++addSecurityRequirement :: Text -> OpenApi -> OpenApi+addSecurityRequirement securityRequirement =+  allOperations+    . security+    %~ ((SecurityRequirement $ HM.singleton securityRequirement []) :)++instance (ToSchema a, AllContentTypes cts) => AttachRequestSchema (ReqBody cts a) where+  attachRequestSchema =+    addRequestBody reqB+      >>> addDefaultResponse400 tname+      >>> components . schemas %~ (<> defs)+   where+    addRequestBody rb = allOperations . requestBody ?~ Inline rb+    tname = "body"+    (defs, ref) = runDeclare (declareSchemaRef (Proxy :: Proxy a)) mempty+    reqB =+      (mempty :: RequestBody)+        & content .~ InsOrdHashMap.fromList [(t, mempty & schema ?~ ref) | t <- allContentTypes @cts]++newtype AsHtml a = AsHtml a+instance ToResponses (AsHtml a) where+  toResponses = (resps, [])+   where+    resps =+      Responses+        { _responsesResponses = mempty & at 200 ?~ resp+        , _responsesDefault = Nothing+        }+    resp =+      Inline+        ( mempty+            & content+              .~ InsOrdHashMap.fromList+                [("text/html", mempty)]+        )++newtype AsBinary a = AsBinary a+instance ToResponses (AsBinary a) where+  toResponses = (resps, [])+   where+    resps =+      Responses+        { _responsesResponses = mempty & at 200 ?~ resp+        , _responsesDefault = Nothing+        }+    resp =+      Inline+        ( mempty+            & content+              .~ InsOrdHashMap.fromList+                [("octet/stream", mempty)]+        )++newtype AsSSE a = AsSSE a++-- | It's just a stub for now+instance ToResponses (AsSSE a) where+  toResponses = (resps, [])+   where+    resps =+      Responses+        { _responsesResponses = mempty & at 200 ?~ resp+        , _responsesDefault = Nothing+        }+    resp =+      Inline+        ( mempty+            & content+              .~ InsOrdHashMap.fromList+                [("text/event-stream", mempty)]+        )++newtype AsMultipart a = AsMultipart a++instance AttachRequestSchema (AsMultipart a) where+  attachRequestSchema =+    addRequestBody reqB+      >>> addDefaultResponse400 tname+   where+    addRequestBody rb = allOperations . requestBody ?~ Inline rb+    tname = "body"+    reqB =+      (mempty :: RequestBody)+        & content .~ InsOrdHashMap.fromList [("multipart/form-data", mempty)]++instance (ToSchema a, AllContentTypes cts) => ToResponses (RespBody cts a) where+  toResponses = (resps, defs)+   where+    (defs, ref) = runDeclare (declareSchemaRef (Proxy :: Proxy a)) mempty+    resps =+      Responses+        { _responsesResponses = mempty & at 200 ?~ resp+        , _responsesDefault = Nothing+        }+    resp =+      Inline+        ( mempty+            & content+              .~ InsOrdHashMap.fromList+                [(t, mempty & schema ?~ ref) | t <- responseContentTypes]+        )++    responseContentTypes = allContentTypes @cts++instance ToResponses NoBody where+  toResponses = (resps, mempty)+   where+    resps =+      Responses+        { _responsesResponses = mempty & at 200 ?~ resp+        , _responsesDefault = Nothing+        }+    resp = Inline mempty++instance ToResponses LazyBytes where+  toResponses = (resps, mempty)+   where+    resps =+      Responses+        { _responsesResponses = mempty & at 200 ?~ resp+        , _responsesDefault = Nothing+        }+    resp =+      Inline+        ( mempty+            & content+              .~ InsOrdHashMap.fromList+                [("application/octet-stream", mempty)]+        )++instance ToResponses Chunks where+  toResponses = toResponses @LazyBytes++instance (ToResponses a, ToResponses (Union as)) => ToResponses (Union (a ': as)) where+  toResponses =+    let (resp, def) = toResponses @a+        (resps, defs) = toResponses @(Union as)+     in (resp <> resps, def <> defs)++instance (ToResponses a, IsStatus status) => ToResponses (WithStatus status a) where+  toResponses =+    let (Responses{..}, def) = toResponses @a+        Status code _ = status @status+     in ( Responses+            { _responsesDefault = Nothing+            , _responsesResponses =+                mempty+                  & at code+                    .~ ( _responsesResponses ^. at code+                          <|> listToMaybe (toList _responsesResponses)+                          <|> _responsesDefault+                       )+            }+        , def+        )++class ToResponses a where+  toResponses :: (Responses, Definitions Schema)++openapi3 ::+  forall m ts st.+  (HandleArgs ts st m, MonadIO m) =>+  OpenApi3Config ->+  Router' OpenApi3 ts m ->+  Router' Void Void m+openapi3 OpenApi3Config{..} r =+  [ Piece (Text.pack openapi3File) /> handleJson GET (pure $ generateOpenApi3 r)+  , Piece (Text.pack staticDir)+      /> [ "index.html" /> getIndex+         , staticFiles defaultExtsMap ui'+         , getIndex+         ]+  ]+ where+  ui' = map (first (dropWhile (== '/'))) ui+  getIndex = handle GET do+    pure $+      Header.AddHeaders+        { headers = Header.AddHeader @"Content-Type" (Header.RawHeaderValue "text/html") :# HNil+        , body = LazyBytes index+        }++  index =+    indexTemplate+      & Text.replace "SWAGGER_UI_SCHEMA" (toUrlPiece openapi3File)+      & Text.replace "SWAGGER_UI_DIR" (toUrlPiece staticDir)+      & preEscapedToMarkup+      & renderHtml++generateOpenApi3 :: forall m ts. Router' OpenApi3 ts m -> OpenApi+generateOpenApi3 = \case+  Capture @a _ pname r ->+    generateOpenApi3 r & openapi3Capture @a pname+  Captures @a _ pname r ->+    generateOpenApi3 r & openapi3Capture @a pname+  Header @a @presence hname _ r ->+    generateOpenApi3 r & opeanapi3Header @presence @a hname+  Request @r _ r -> generateOpenApi3 r & attachRequestSchema @r+  HideIntrospection _ -> mempty+  Piece path r -> prependPath (Text.unpack path) (generateOpenApi3 r)+  Middleware _ r -> generateOpenApi3 r+  Alt rs -> foldMap generateOpenApi3 rs+  MapArgs _ r -> generateOpenApi3 r+  Description (toOpenApi3Description -> desc) r -> case desc of+    DescriptionText txt -> generateOpenApi3 r & allOperations . description %~ (Just txt <>)+    SummaryText txt -> generateOpenApi3 r & allOperations . summary %~ (Just txt <>)+  QueryParam @a @presence bname _ r -> generateOpenApi3 r & openapi3QueryParam @presence @a bname+  Handle @o httpMethod _ ->+    let+      method :: Lens' PathItem (Maybe Operation)+      method = case httpMethod of+        "GET" -> get+        "POST" -> post+        "PATCH" -> patch+        "DELETE" -> delete+        "PUT" -> put+        "TRACE" -> trace+        "OPTIONS" -> options+        "HEAD" -> head_+        _ -> lens (const Nothing) const+      (resp, defs) = toResponses @o+     in+      mempty+        & paths . at "/" ?~ (mempty & method ?~ (mempty{_operationResponses = resp}))+        & components . schemas .~ defs++openapi3QueryParam :: forall presence a. (IsRequired presence, ToParamSchema a) => Bytes.ByteString -> OpenApi -> OpenApi+openapi3QueryParam bname = addParam param >>> addDefaultResponse400 tname+ where+  tname = Text.decodeUtf8 bname+  param =+    mempty+      & name .~ tname+      & required ?~ isRequired @presence+      & in_ .~ ParamQuery+      & schema ?~ Inline (toParamSchema (Proxy @a))++openapi3Capture :: forall a. (ToParamSchema a) => Text -> OpenApi -> OpenApi+openapi3Capture tname =+  addParam param+    >>> prependPath capture_+    >>> addDefaultResponse404 tname+ where+  sname = Text.unpack tname+  capture_ = "{" <> sname <> "}"+  param =+    mempty+      & name .~ tname+      & required ?~ True+      & in_ .~ ParamPath+      & schema ?~ Inline (toParamSchema (Proxy :: Proxy a))++opeanapi3Header :: forall presence a. (IsRequired presence, ToParamSchema a) => Http.HeaderName -> OpenApi -> OpenApi+opeanapi3Header hname =+  addParam param+    >>> addDefaultResponse400 tname+ where+  tname = Text.decodeUtf8 $ CI.original hname+  param =+    mempty+      & name .~ tname+      & required ?~ isRequired @presence+      & in_ .~ ParamHeader+      & schema ?~ Inline (toParamSchema (Proxy :: Proxy a))++addParam :: Param -> OpenApi -> OpenApi+addParam param = allOperations . parameters %~ (Inline param :)++addDefaultResponse404 :: ParamName -> OpenApi -> OpenApi+addDefaultResponse404 pname = setResponseWith (\old _new -> alter404 old) 404 (return response404)+ where+  sname = markdownCode pname+  description404 = sname <> " not found"+  alter404 = description %~ ((sname <> " or ") <>)+  response404 = mempty & description .~ description404++addDefaultResponse400 :: ParamName -> OpenApi -> OpenApi+addDefaultResponse400 pname = setResponseWith (\old _new -> alter400 old) 400 (return response400)+ where+  sname = markdownCode pname+  description400 = "Invalid " <> sname+  alter400 = description %~ (<> (" or " <> sname))+  response400 = mempty & description .~ description400++markdownCode :: Text -> Text+markdownCode s = "`" <> s <> "`"