servant-py (empty) → 0.1.0.1
raw patch · 12 files changed
+1195/−0 lines, 12 filesdep +QuickCheckdep +aesondep +basesetup-changed
Dependencies added: QuickCheck, aeson, base, base-compat, blaze-html, bytestring, charset, filepath, hspec, hspec-expectations, lens, servant, servant-blaze, servant-foreign, servant-py, servant-server, stm, text, wai, warp
Files
- LICENSE +30/−0
- README.md +175/−0
- Setup.hs +2/−0
- examples/Main.hs +77/−0
- servant-py.cabal +92/−0
- src/Servant/PY.hs +90/−0
- src/Servant/PY/Internal.hs +370/−0
- src/Servant/PY/Python.hs +79/−0
- src/Servant/PY/Requests.hs +75/−0
- test/Servant/PY/InternalSpec.hs +143/−0
- test/Servant/PYSpec.hs +61/−0
- test/Spec.hs +1/−0
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Erik Aker (c) 2017++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Author name here nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,175 @@+# servant-py++This library lets you derive automatically Python functions that let you query each endpoint of a *servant* webservice.++Currently, the only supported method for generating requests is via the `requests` library, which is the recommended way to generate HTTP requests in the Python world (even among Python core devs).++## Inspiration++This library is largely inspired by [servant-js](https://github.com/haskell-servant/servant-js) and by the fantastic work of the Servant team in general. Any good ideas you find in here are from their work (any mistakes are almost entirely mine, however).++## Example++There are two different styles of function-return supported here: `DangerMode` and `RawResponse`.++The latter returns the raw response from issuing the request and the former calls `raise_for_status` and then attempts to return `resp.json()`. You can switch which style you'd like to use by creating a proper `CommonGeneratorOptions` object.++The default options just chucks it all to the wind and goes for `DangerMode` (because, seriously, we're using Haskell to generate Python here...).++Following is an example of using the Servant DSL to describe endpoints and then using `servant-py` to create Python clients for those endpoints.++#### Servant DSL API Description++``` haskell+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE TypeOperators #-}++module Main where++import Data.Aeson+import qualified Data.ByteString.Char8 as B+import Data.Proxy+import qualified Data.Text as T+import GHC.Generics+import Servant+import System.FilePath++import Servant.PY++-- * A simple Counter data type+newtype Counter = Counter { value :: Int }+ deriving (Generic, Show, Num)+instance ToJSON Counter++data LoginForm = LoginForm+ { username :: !T.Text+ , password :: !T.Text+ , otherMissing :: Maybe T.Text+ } deriving (Eq, Show, Generic)+instance ToJSON LoginForm++-- * Our API type+type TestApi = "counter-req-header" :> Post '[JSON] Counter+ :<|> "counter-queryparam"+ :> QueryParam "sortby" T.Text+ :> Header "Some-Header" T.Text :> Get '[JSON] Counter+ :<|> "login-queryflag" :> QueryFlag "published" :> Get '[JSON] LoginForm+ :<|> "login-params-authors-with-reqBody"+ :> QueryParams "authors" T.Text+ :> ReqBody '[JSON] LoginForm :> Post '[JSON] LoginForm+ :<|> "login-with-path-var-and-header"+ :> Capture "id" Int+ :> Capture "Name" T.Text+ :> Capture "hungrig" Bool+ :> ReqBody '[JSON] LoginForm+ :> Post '[JSON] (Headers '[Header "test-head" B.ByteString] LoginForm)++testApi :: Proxy TestApi+testApi = Proxy++-- where our static files reside+result :: FilePath+result = "examples"++main :: IO ()+main = writePythonForAPI testApi requests (result </> "api.py")+```++#### Generated Python Code++If you build the above and run it, you will get some output that looks like the following:++```python+from urllib import parse++import requests++def post_counterreqheader():+ """+ POST "counter-req-header"++ """+ url = "http://localhost:8000/counter-req-header"++ resp = requests.post(url)+ resp.raise_for_status()+ return resp.json()+++def get_counterqueryparam(sortby, headerSomeHeader):+ """+ GET "counter-queryparam"++ """+ url = "http://localhost:8000/counter-queryparam"++ headers = {"Some-Header": headerSomeHeader}+ params = {"sortby": sortby}+ resp = requests.get(url,+ headers=headers,+ params=params)++ resp.raise_for_status()+ return resp.json()+++def get_loginqueryflag(published):+ """+ GET "login-queryflag"++ """+ url = "http://localhost:8000/login-queryflag"++ params = {"published": published}+ resp = requests.get(url,+ params=params)++ resp.raise_for_status()+ return resp.json()+++def post_loginparamsauthorswithreqBody(authors, data):+ """+ POST "login-params-authors-with-reqBody"++ """+ url = "http://localhost:8000/login-params-authors-with-reqBody"++ params = {"authors": authors}+ resp = requests.post(url,+ params=params,+ json=data)++ resp.raise_for_status()+ return resp.json()+++def post_loginwithpathvarandheader_by_id_by_Name_by_hungrig(id, Name, hungrig, data):+ """+ POST "login-with-path-var-and-header/{id}/{Name}/{hungrig}"+ Args:+ id+ Name+ hungrig+ """+ url = "http://localhost:8000/login-with-path-var-and-header/{id}/{Name}/{hungrig}".format(+ id=parse.quote(id),+ Name=parse.quote(Name),+ hungrig=parse.quote(hungrig))++ resp = requests.post(url,+ json=data)++ resp.raise_for_status()+ return resp.json()+```++If you would like to compile and run this example yourself, you can do that like so:++```+$ stack build --flag servant-py:example+$ stack exec servant-py-exe+$ cat examples/api.py+```
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ examples/Main.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE TypeOperators #-}++module Main where++import Data.Aeson+import qualified Data.ByteString.Char8 as B+import Data.Data+import qualified Data.Text as T+import GHC.Generics+import Servant+import Servant.Foreign+import System.FilePath++import Servant.PY+import Servant.PY.Python++-- * A simple Counter data type+newtype Counter = Counter { value :: Int }+ deriving (Generic, Show, Num, Data, Typeable)+instance ToJSON Counter+-- instance HasForeignType Python T.Text Counter where+-- typeFor _ _ _ = "{\"value\": int}"++data LoginForm = LoginForm+ { username :: !T.Text+ , password :: !T.Text+ , otherMissing :: Maybe T.Text+ } deriving (Eq, Show, Generic, Typeable, Data)+instance ToJSON LoginForm++-- You could provide a direct instance of your types to represent them as strings+-- instance HasForeignType Python T.Text LoginForm where+-- typeFor _ _ _ = "{\"username\": str, \"password\": str, \"otherMissing\": Maybe Text}"+++-- Alternately, if you make your instance derive Typeable and Data, and+-- you enable the pragma DeriveDataTypeable,+-- then you can use recordToDict to automatically derive your records+instance HasForeignType Python T.Text LoginForm where+ typeFor _ _ _ = recordToDict (undefined :: LoginForm) LoginForm++instance HasForeignType Python T.Text Counter where+ typeFor _ _ _ = recordToDict (undefined :: Counter) Counter++-- * Our API type+type TestApi = "counter-req-header" :> Post '[JSON] Counter+ :<|> "counter-queryparam"+ :> QueryParam "sortby" T.Text+ :> Header "Some-Header" T.Text :> Get '[JSON] Counter+ :<|> "login-queryflag" :> QueryFlag "published" :> Get '[JSON] LoginForm+ :<|> "login-params-authors-with-reqBody"+ :> QueryParams "authors" T.Text+ :> ReqBody '[JSON] LoginForm :> Post '[JSON] LoginForm+ :<|> "login-with-path-var-and-header"+ :> Capture "id" Int+ :> Capture "Name" T.Text+ :> Capture "hungrig" Bool+ :> ReqBody '[JSON] LoginForm+ :> Post '[JSON] (Headers '[Header "test-head" B.ByteString] LoginForm)++testApi :: Proxy TestApi+testApi = Proxy++-- where our static files reside+result :: FilePath+result = "examples"++main :: IO ()+main = do+ writePythonForAPI testApi requests (result </> "api.py")+ writeTypedPythonForAPI testApi requests (result </> "api_typed.py")
+ servant-py.cabal view
@@ -0,0 +1,92 @@+name: servant-py+version: 0.1.0.1+synopsis: Automatically derive python functions to query servant webservices.+description:+ Automatically derive python functions to query servant webservices.+ .+ Supports deriving functions using Python's requests library.+homepage: https://github.com/pellagic-puffbomb/servant-py#readme+license: BSD3+license-file: LICENSE+author: Erik Aker+maintainer: eraker@gmail.com+copyright: 2017 Erik Aker+category: Web+build-type: Simple+extra-source-files: README.md+cabal-version: >=1.10++flag example+ description: Build the example too+ manual: True+ default: False++library+ hs-source-dirs: src+ exposed-modules: Servant.PY+ , Servant.PY.Requests+ , Servant.PY.Internal+ , Servant.PY.Python+ build-depends: base >= 4.7 && < 5+ , aeson+ , bytestring+ , charset+ , lens+ , servant-foreign+ , servant+ , text+ default-language: Haskell2010+ default-extensions: OverloadedStrings+ ghc-options: -Wall++executable servant-py-exe+ ghc-options: -Wall -O2 -threaded -rtsopts -with-rtsopts=-N+ hs-source-dirs: examples++ if flag(example)+ buildable: True+ else+ buildable: False+ hs-source-dirs: examples+ main-is: Main.hs+ build-depends: base+ , blaze-html+ , bytestring+ , servant-py+ , stm+ , aeson+ , wai+ , servant+ , servant-foreign+ , servant-server+ , servant-blaze+ , text+ , filepath+ , warp+ default-language: Haskell2010++test-suite servant-py-test+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ ghc-options: -Wall+ main-is: Spec.hs+ other-modules:+ Servant.PYSpec+ Servant.PY.InternalSpec+ build-depends: base+ , servant-py+ , aeson+ , base-compat+ , bytestring+ , hspec+ , hspec-expectations+ , lens+ , QuickCheck+ , servant+ , servant-foreign+ , text+ default-language: Haskell2010++source-repository head+ type: git+ location: https://github.com/pellagic-puffbomb/servant-py
+ src/Servant/PY.hs view
@@ -0,0 +1,90 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE TypeOperators #-}++module Servant.PY ( -- * Generating python code from an API type+ PythonGenerator+ , python+ , pythonTyped+ , writePythonForAPI+ , pyForAPI+ , pyTypedForAPI+ , writeTypedPythonForAPI+ , -- * Options common to all generators+ CommonGeneratorOptions(..)+ , defCommonGeneratorOptions++ -- Requests library+ , requests++ , -- * Function renamers+ concatCase+ , snakeCase+ , camelCase++ , -- * Misc.+ listFromAPI+ , NoTypes+ , GenerateList(..)+ , FunctionName(..)+ ) where++import Data.Proxy+import Data.Text (Text)+import qualified Data.Text as T+import Servant.Foreign++import Servant.PY.Internal+import Servant.PY.Python+import Servant.PY.Requests++-- | Generate the data necessary to generate Python code+-- for all the endpoints of an API, as ':<|>'-separated values+-- of type 'PyRequest'.+python :: HasForeign NoTypes NoContent api => Proxy api -> Foreign NoContent api+python p = foreignFor (Proxy :: Proxy NoTypes) (Proxy :: Proxy NoContent) p defReq++-- | Generate the data necessary to generate Python code+-- for all the endpoints of an API, but try to get as much type-information+-- into Python docstrings, in order to aid discoverability of client functions.+pythonTyped :: HasForeign Python Text api => Proxy api -> Foreign Text api+pythonTyped p = foreignFor (Proxy :: Proxy Python) (Proxy :: Proxy Text) p defReq++-- | Directly generate all the Python functions for your API+-- from a 'Proxy' for your API type. You can then write it to+-- a file or integrate it in a page, for example.+pyForAPI :: (HasForeign NoTypes NoContent api, GenerateList NoContent (Foreign NoContent api))+ => Proxy api -- ^ proxy for your API type+ -> PythonGenerator -- ^ python code generator to use (requests is the only one for now)+ -> Text -- ^ a text that you can embed in your pages or write to a file+pyForAPI p gen = gen (UnTypedPythonRequest <$> listFromAPI (Proxy :: Proxy NoTypes) (Proxy :: Proxy NoContent) p)++-- | Directly generate all the Python functions for your API+-- from a 'Proxy' for your API type using the given generator+-- and write the resulting code to a file at the given path.+writePythonForAPI :: (HasForeign NoTypes NoContent api, GenerateList NoContent (Foreign NoContent api))+ => Proxy api -- ^ proxy for your API type+ -> PythonGenerator -- ^ python code generator to use (requests is the only one for now)+ -> FilePath -- ^ path to the file you want to write the resulting javascript code into+ -> IO ()+writePythonForAPI p gen fp = writeFile fp (T.unpack $ pyForAPI p gen)+++-- | Directly generate all the Python functions for your API+-- from a 'Proxy' for your API type. You can then write it to+-- a file or integrate it in a page, for example.+pyTypedForAPI :: (HasForeign Python T.Text api, GenerateList T.Text (Foreign T.Text api))+ => Proxy api -- ^ proxy for your API type+ -> PythonGenerator -- ^ python code generator to use (requests is the only one for now)+ -> Text -- ^ a text that you can embed in your pages or write to a file+pyTypedForAPI p gen = gen (TypedPythonRequest <$> listFromAPI (Proxy :: Proxy Python) (Proxy :: Proxy T.Text) p)+++writeTypedPythonForAPI :: (HasForeign Python T.Text api, GenerateList T.Text (Foreign T.Text api))+ => Proxy api -- ^ proxy for your API type+ -> PythonGenerator -- ^ python code generator to use (requests is the only one for now)+ -> FilePath -- ^ path to the file you want to write the resulting javascript code into+ -> IO ()+writeTypedPythonForAPI p gen fp = writeFile fp (T.unpack $ pyTypedForAPI p gen)
+ src/Servant/PY/Internal.hs view
@@ -0,0 +1,370 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE KindSignatures #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}++module Servant.PY.Internal+ ( PythonGenerator+ , ReturnStyle(..)+ , PythonRequest(..)+ , PyRequestArgs(..)+ , CommonGeneratorOptions(..)+ , defCommonGeneratorOptions+ , defaultPyIndent+ , indent+ , Indent+ , indenter+ , makePyUrl+ , makePyUrl'+ , segmentToStr+ , capturesToFormatArgs+ , toValidFunctionName+ , functionName+ , toPyHeader+ , retrieveHeaders+ , getHeaderDict+ , retrieveHeaderText+ , toPyDict+ , toPyParams+ , getParams+ , paramNames+ , captures+ , getMethod+ , hasBody+ , withFormattedCaptures+ , buildDocString+ , buildHeaderDict+ , functionArguments+ , formatBuilder+ , remainingReqCall+ -- re-exports+ , (:<|>)(..)+ , (:>)+ , defReq+ , reqHeaders+ , HasForeign(..)+ , HasForeignType(..)+ , GenerateList(..)+ , NoTypes+ , ArgType(..)+ , HeaderArg(..)+ , QueryArg(..)+ , Req(..)+ , Segment(..)+ , SegmentType(..)+ , Url(..)+ , Path+ , Arg(..)+ , FunctionName(..)+ , PathSegment(..)+ , concatCase+ , snakeCase+ , camelCase+ , ReqBody+ , JSON+ , FormUrlEncoded+ , Post+ , Get+ , Raw+ , Header+ ) where++import Control.Lens hiding (List)+import qualified Data.CharSet as Set+import qualified Data.CharSet.Unicode.Category as Set+import Data.Data+import Data.Maybe (isJust)+import Data.Monoid+import Data.Text (Text)+import qualified Data.Text as T+import Data.Text.Encoding (decodeUtf8)+import GHC.TypeLits+import Servant.Foreign+++-- A 'PythonGenerator' just takes the data found in the API type+-- for each endpoint and generates Python code as Text.+-- There are `NoContent` requests and Text requests with typing information.+type PythonGenerator = [PythonRequest] -> Text+data PythonRequest = TypedPythonRequest (Req Text)+ | UnTypedPythonRequest (Req NoContent)+ deriving (Eq, Show)++-- We'd like to encode at the type-level that indentation+-- is some multiplication of whitespace (sorry: never tabs!)+type Indent = (" " :: Symbol)++indent :: Proxy Indent+indent = Proxy++-- The defaultPyIndent function is 4 spaces.+-- You can create a different indentation width by passing a different Int to indenter.+defaultPyIndent :: Proxy Indent -> Text+defaultPyIndent = indenter 4++-- But you can create alternatives by specializing the `indenter` function+-- to other Ints. Then, to get your indentation, pass `indent` to the created function+indenter :: Int -> Proxy Indent -> Text+indenter width space = mconcat $ width `replicate` (T.pack . symbolVal) space+{-# INLINE indenter #-}+++-- Created python Functions can have different return styles+data ReturnStyle = DangerMode -- Throw caution to the wind and return JSON+ | RawResponse -- Return response object itself+++data PyRequestArgs = PyRequestArgs {+ hasHeaders :: Bool+ , hasParams :: Bool+ , hasData :: Bool+ } deriving (Show)++-- | This structure is used by specific implementations to let you+-- customize the output+data CommonGeneratorOptions = CommonGeneratorOptions+ {+ functionNameBuilder :: FunctionName -> Text+ -- ^ function generating function names+ , requestBody :: Text+ -- ^ name used when a user want to send the request body+ -- (to let you redefine it)+ , urlPrefix :: Text+ -- ^ a prefix we should add to the Url in the codegen+ , indentation :: Proxy Indent -> Text+ -- ^ indentation to use for Python codeblocks. Create this function by passing an Int to indenter.+ , returnMode :: ReturnStyle+ -- ^ whether the generated functions return the raw response or content+ }++-- | Default options.+--+-- @+-- > defCommonGeneratorOptions = CommonGeneratorOptions+-- > { functionNameBuilder = snakeCase+-- > , requestBody = "body"+-- > , urlPrefix = ""+-- > , indentation = " " -- 4 spaces+-- > , returnMode = DangerMode+-- > }+-- @+defCommonGeneratorOptions :: CommonGeneratorOptions+defCommonGeneratorOptions = CommonGeneratorOptions+ {+ functionNameBuilder = snakeCase+ , requestBody = "data"+ , urlPrefix = "http://localhost:8000"+ , indentation = defaultPyIndent+ , returnMode = DangerMode+ }++-- | Attempts to reduce the function name provided to that allowed by @'Foreign'@.+--+-- For valid Python function identifiers see the following:+-- https://docs.python.org/3.2/reference/lexical_analysis.html#identifiers+-- valid start chars: Lu, Ll, Lt, Lm, Lo, Nl, the underscore+-- valid continuation chars: valid start chars <> Mn, Mc, Nd, Pc+toValidFunctionName :: Text -> Text+toValidFunctionName t =+ case T.uncons t of+ Just (x,xs) ->+ setFirstChar x `T.cons` T.filter remainder xs+ Nothing -> "_"+ where+ setFirstChar c = if Set.member c firstLetterOK then c else '_'+ remainder c = Set.member c remainderOK+ firstLetterOK = filterBmpChars $ mconcat+ [ Set.fromDistinctAscList "_"+ , Set.lowercaseLetter+ , Set.uppercaseLetter+ , Set.titlecaseLetter+ , Set.modifierLetter+ , Set.otherLetter+ , Set.letterNumber+ ]+ remainderOK = firstLetterOK+ <> filterBmpChars (mconcat+ [ Set.nonSpacingMark+ , Set.spacingCombiningMark+ , Set.decimalNumber+ , Set.connectorPunctuation+ ])++functionName :: CommonGeneratorOptions -> PythonRequest -> Text+functionName opts (TypedPythonRequest req) = toValidFunctionName (functionNameBuilder opts $ req ^. reqFuncName)+functionName opts (UnTypedPythonRequest req) = toValidFunctionName (functionNameBuilder opts $ req ^. reqFuncName)++-- Identifiers can only contain codepoints in the Basic Multilingual Plane+-- that is, codepoints that can be encoded in UTF-16 without a surrogate pair (UCS-2)+-- that is, codepoints that can fit in 16-bits, up to 0xffff (65535)+filterBmpChars :: Set.CharSet -> Set.CharSet+filterBmpChars = Set.filter (< '\65536')++-- This function creates a dict where the keys are string representations of variable+-- names. This is due to the way arguments are passed into the function, and these+-- arguments named params. In other words, [("key", "key")] becomes: {"key": key}+toPyDict :: Text -> [Text] -> Text+toPyDict offset dict+ | null dict = "{}"+ | otherwise = "{" <> T.intercalate (",\n" <> offset) insides <> "}"+ where insides = combiner <$> dict+ combiner a = "\"" <> a <> "\": " <> a++-- Query params are passed into the function that makes the request, so we make+-- a python dict out of them.+getParams :: Text -> PythonRequest -> Text+getParams offset (TypedPythonRequest req) = toPyParams offset $ req ^.. reqUrl.queryStr.traverse+getParams offset (UnTypedPythonRequest req) = toPyParams offset $ req ^.. reqUrl.queryStr.traverse++toPyParams :: Text -> [QueryArg f] -> Text+toPyParams _ [] = ""+toPyParams offset qargs = toPyDict offset paramList+ where paramList = fmap (\qarg -> qarg ^. queryArgName.argName._PathSegment) qargs++-- We also need to make sure we can retrieve just the param names for function args.+paramNames :: PythonRequest -> [Text]+paramNames (TypedPythonRequest req) = map (view $ queryArgName . argPath) $ req ^.. reqUrl.queryStr.traverse+paramNames (UnTypedPythonRequest req) = map (view $ queryArgName . argPath) $ req ^.. reqUrl.queryStr.traverse++-- Request headers are also passed into the function that makes the request, so we make+-- a python dict out of them.+toPyHeader :: HeaderArg f -> Text+toPyHeader (HeaderArg n)+ = toValidFunctionName ("header" <> n ^. argName . _PathSegment)+toPyHeader (ReplaceHeaderArg n p)+ | pn `T.isPrefixOf` p = pv <> " + \"" <> rp <> "\""+ | pn `T.isSuffixOf` p = "\"" <> rp <> "\" + " <> pv+ | pn `T.isInfixOf` p = "\"" <> T.replace pn ("\" + " <> pv <> " + \"") p+ <> "\""+ | otherwise = p+ where+ pv = toValidFunctionName ("header" <> n ^. argName . _PathSegment)+ pn = "{" <> n ^. argName . _PathSegment <> "}"+ rp = T.replace pn "" p++buildHeaderDict :: [HeaderArg f] -> Text+buildHeaderDict [] = ""+buildHeaderDict hs = "{" <> headers <> "}"+ where headers = T.intercalate ", " $ map headerStr hs+ headerStr header = "\"" <> header ^. headerArg . argPath <> "\": "+ <> toPyHeader header++getHeaderDict :: PythonRequest -> Text+getHeaderDict (TypedPythonRequest req) = buildHeaderDict $ req ^. reqHeaders+getHeaderDict (UnTypedPythonRequest req) = buildHeaderDict $ req ^. reqHeaders++retrieveHeaders :: PythonRequest -> [Text]+retrieveHeaders (TypedPythonRequest req) = retrieveHeaderText <$> req ^. reqHeaders+retrieveHeaders (UnTypedPythonRequest req) = retrieveHeaderText <$> req ^. reqHeaders++retrieveHeaderText :: forall f. HeaderArg f -> Text+retrieveHeaderText header = header ^. headerArg . argPath+++functionArguments :: forall f. Req f -> Text+functionArguments req =+ mconcat [ T.intercalate ", " args]+ where+ args = captures' req ++ qparam ++ body ++ headers++ qparam = map ((<>) "param_" . view (queryArgName . argPath)) queryParams++ body = if isJust $ req ^. reqBody+ then ["data"]+ else []+ queryParams = req ^.. reqUrl . queryStr . traverse+ headers = map ((<>) "header_"+ . view (headerArg . argPath)+ ) $ req ^. reqHeaders++captures :: PythonRequest -> [Text]+captures (TypedPythonRequest req) = captures' req+captures (UnTypedPythonRequest req) = captures' req+++captures' :: forall f. Req f -> [Text]+captures' req = map (view argPath . captureArg)+ . filter isCapture+ $ req ^. reqUrl.path++makePyUrl :: CommonGeneratorOptions -> PythonRequest -> Text -> Text+makePyUrl opts (TypedPythonRequest req) offset = makePyUrl' opts req offset+makePyUrl opts (UnTypedPythonRequest req) offset = makePyUrl' opts req offset++makePyUrl' :: forall f. CommonGeneratorOptions -> Req f -> Text -> Text+makePyUrl' opts req offset = if url' == "\"" then "\"/\"" else url'+ where url' = "\"" <> urlPrefix opts <> "/"+ <> getSegments pathParts+ <> withFormattedCaptures offset pathParts+ pathParts = req ^.. reqUrl.path.traverse++getSegments :: forall f. [Segment f] -> Text+getSegments segments = if null segments+ then ""+ else T.intercalate "/" (map segmentToStr segments) <> "\""++withFormattedCaptures :: Text -> [Segment f] -> Text+withFormattedCaptures offset segments = formattedCaptures (capturesToFormatArgs segments)+ where formattedCaptures [] = ""+ formattedCaptures xs = ".format(\n" <> offset+ <> T.intercalate (",\n" <> offset) (map formatBuilder xs)+ <> ")"++formatBuilder :: Text -> Text+formatBuilder val = val <> "=parse.quote(str("<> val <> "))"++segmentToStr :: Segment f -> Text+segmentToStr (Segment (Static s)) = s ^. _PathSegment+segmentToStr (Segment (Cap s)) = "{" <> s ^. argName . _PathSegment <> "}"++capturesToFormatArgs :: [Segment f] -> [Text]+capturesToFormatArgs segments = map getSegment $ filter isCapture segments+ where getSegment (Segment (Cap a)) = getCapture a+ getSegment _ = ""+ getCapture s = s ^. argName . _PathSegment++captureArgsWithTypes :: [Segment Text] -> [Text]+captureArgsWithTypes segments = map getSegmentArgType (filter isCapture segments)+ where getSegmentArgType (Segment (Cap a)) = pathPart a <> " (" <> a ^. argType <> ")"+ getSegmentArgType _ = ""+ pathPart s = s ^. argName . _PathSegment+++buildDocString :: PythonRequest -> CommonGeneratorOptions -> Text -> Text+buildDocString (TypedPythonRequest req) opts returnVal = buildDocString' req opts args returnVal+ where args = captureArgsWithTypes $ req ^.. reqUrl.path.traverse+buildDocString (UnTypedPythonRequest req) opts returnVal = buildDocString' req opts args returnVal+ where args = capturesToFormatArgs $ req ^.. reqUrl.path.traverse++buildDocString' :: forall f. Req f -> CommonGeneratorOptions -> [Text] -> Text -> Text+buildDocString' req opts args returnVal = T.toUpper method <> " \"" <> url <> "\n"+ <> includeArgs <> "\n\n"+ <> indent' <> "Returns: " <> "\n"+ <> indent' <> indent' <> returnVal+ where method = decodeUtf8 $ req ^. reqMethod+ url = getSegments $ req ^.. reqUrl.path.traverse+ includeArgs = if null args then "" else argDocs+ argDocs = indent' <> "Args: " <> "\n"+ <> indent' <> indent' <> T.intercalate ("\n" <> indent' <> indent') args+ indent' = indentation opts indent++getMethod :: PythonRequest -> Text+getMethod (TypedPythonRequest req) = decodeUtf8 $ req ^. reqMethod+getMethod (UnTypedPythonRequest req) = decodeUtf8 $ req ^. reqMethod++hasBody :: PythonRequest -> Bool+hasBody (TypedPythonRequest req) = isJust (req ^. reqBody)+hasBody (UnTypedPythonRequest req) = isJust (req ^. reqBody)++remainingReqCall :: PyRequestArgs -> Int -> Text+remainingReqCall reqArgs width+ | null argsAsList = ")"+ | length argsAsList == 1 = ",\n" <> offset <> head argsAsList <> ")\n"+ | otherwise = ",\n" <> offset <> T.intercalate (",\n" <> offset) argsAsList <> ")\n"+ where argsAsList = requestArgsToList reqArgs+ offset = mconcat $ replicate width " "++requestArgsToList :: PyRequestArgs -> [Text]+requestArgsToList reqArgs = map snd . filter fst $ zip bools strings+ where bools = [hasHeaders reqArgs, hasParams reqArgs, hasData reqArgs]+ strings = ["headers=headers", "params=params", "json=data"]
+ src/Servant/PY/Python.hs view
@@ -0,0 +1,79 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE KindSignatures #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeOperators #-}++module Servant.PY.Python where++import qualified Data.ByteString.Char8 as B+import qualified Data.ByteString.Lazy as LB+import Data.Data+import Data.Monoid ((<>))+import Data.Text (Text)+import qualified Data.Text as T+import GHC.TypeLits+import Servant.Foreign++data Python++getFieldsForInstance :: forall a. Data a => a -> [Text]+getFieldsForInstance = map T.pack . mconcat . map constrFields . getConstr+ where getConstr = dataTypeConstrs . dataTypeOf++getFieldTypesForType :: forall a. Typeable a => a -> [Text]+getFieldTypesForType = init . T.splitOn " -> " . T.pack . show . typeOf++recordToDict :: forall a b. (Data a, Typeable b) => a -> b -> T.Text+recordToDict fieldsInstance fieldsType = "{" <> T.intercalate ", " (map printPair fieldPairs) <> "}"+ where printPair (c, d) = "\"" <> c <> "\": " <> d+ fieldPairs = zip fieldNames fieldTypes+ fieldNames = getFieldsForInstance fieldsInstance+ fieldTypes = getFieldTypesForType fieldsType++instance HasForeignType Python Text NoContent where+ typeFor _ _ _ = "None"++instance HasForeignType Python Text Int where+ typeFor _ _ _ = "int"++instance HasForeignType Python Text Bool where+ typeFor _ _ _ = "bool"++instance HasForeignType Python Text String where+ typeFor _ _ _ = "str"++instance HasForeignType Python Text Text where+ typeFor _ _ _ = "str"++instance HasForeignType Python Text LB.ByteString where+ typeFor _ _ _ = "str"++instance HasForeignType Python Text B.ByteString where+ typeFor _ _ _ = "str"++instance HasForeignType Python Text JSON where+ typeFor _ _ _ = "dict"++instance HasForeignType Python T.Text (a :: Symbol) where+ typeFor _ _ _ = "str"++instance HasForeignType Python Text a => HasForeignType Python Text (Header a) where+ typeFor lang ftype _ = "dict" <> typeFor lang ftype (Proxy :: Proxy a)++instance HasForeignType Python Text a => HasForeignType Python Text (Headers '[Header a b] c) where+ typeFor lang ftype _ = "dict" <> typeFor lang ftype (Proxy :: Proxy a)++instance HasForeignType Python Text a => HasForeignType Python Text [Header a b] where+ typeFor lang ftype _ = "[dict of " <> typeFor lang ftype (Proxy :: Proxy a) <> "]"++instance HasForeignType Python Text a => HasForeignType Python Text (Headers a) where+ typeFor lang ftype _ = "dict" <> typeFor lang ftype (Proxy :: Proxy a)++instance HasForeignType Python Text a => HasForeignType Python Text (Maybe a) where+ typeFor lang ftype _ = "Optional" <> typeFor lang ftype (Proxy :: Proxy a)++instance HasForeignType Python Text a => HasForeignType Python Text [a] where+ typeFor lang ftype _ = "list of " <> typeFor lang ftype (Proxy :: Proxy a)
+ src/Servant/PY/Requests.hs view
@@ -0,0 +1,75 @@+module Servant.PY.Requests where++import Data.Monoid+import Data.Proxy+import Data.Text (Text)+import qualified Data.Text as T++import Servant.PY.Internal++-- | Generate python functions that use the requests library.+-- Uses 'defCommonGeneratorOptions' for the generator options.+requests :: PythonGenerator+requests reqs = defPyImports <> mconcat (map requestsWithDef reqs)++-- | Generate python functions that use the requests library.+-- Lets you specify your own 'CommonGeneratorOptions'.+requestsWith :: CommonGeneratorOptions -> [PythonRequest] -> Text+requestsWith opts reqs = mconcat (map (generatePyRequestWith opts) reqs)++-- | python codegen using requests with default options+requestsWithDef :: PythonRequest -> Text+requestsWithDef = generatePyRequestWith defCommonGeneratorOptions++defPyImports :: Text+defPyImports =+ T.unlines+ [ "from urllib import parse"+ , "" -- Separate stdlib from 3rd-party imports+ , "import requests"+ ]++-- | python codegen with requests+generatePyRequestWith :: CommonGeneratorOptions -> PythonRequest -> Text+generatePyRequestWith opts req = "\n" <>+ "def " <> functionName opts req <> "(" <> argsStr <> "):\n"+ <> indent' <> docStringMarker+ <> indent' <> buildDocString req opts returnVal <> "\n"+ <> indent' <> docStringMarker+ <> indent' <> "url = " <> makePyUrl opts req (indent' <> indent') <> "\n\n"+ <> headerDef+ <> paramDef+ <> requestBuilder <> "(url" <> remaining (T.length requestBuilder + 1) <> "\n"+ <> functionReturn (returnMode opts) (indentation opts)+ <> "\n\n"+ where argsStr = T.intercalate ", " args+ args = captures req+ ++ qparams+ ++ body+ ++ map (toValidFunctionName+ . (<>) "header"+ ) hs+ hs = retrieveHeaders req+ qparams = paramNames req+ method = T.toLower $ getMethod req+ remaining = remainingReqCall $ PyRequestArgs (not . null $ hs) (not . null $ qparams) (hasBody req)+ paramDef+ | null qparams = ""+ | otherwise = indent' <> "params = " <> getParams (indent' <> indent') req <> "\n"+ headerDef+ | null hs = ""+ | otherwise = indent' <> "headers = " <> getHeaderDict req <> "\n"+ requestBuilder = indent' <> "resp = requests." <> method+ body = [requestBody opts | hasBody req]+ indent' = indentation opts indent+ docStringMarker = "\"\"\"\n"+ returnVal = case returnMode opts of+ DangerMode -> "JSON response from the endpoint"+ RawResponse -> "response (requests.Response) from issuing the request"++functionReturn :: ReturnStyle -> (Proxy Indent -> T.Text) -> T.Text+functionReturn DangerMode pyindenter = indent' <> "resp.raise_for_status()\n"+ <> indent' <> "return resp.json()"+ where indent' = pyindenter indent+functionReturn RawResponse pyindenter = indent' <> "return resp"+ where indent' = pyindenter indent
+ test/Servant/PY/InternalSpec.hs view
@@ -0,0 +1,143 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TypeOperators #-}+{-# OPTIONS_GHC -fno-warn-orphans #-}++module Servant.PY.InternalSpec where++import Control.Lens+import Data.Aeson+import qualified Data.ByteString.Char8 as B+import Data.Monoid+import Data.Proxy+import Data.Text (Text)+import qualified Data.Text as T+import GHC.Generics+import Prelude ()+import Prelude.Compat+import Test.Hspec hiding+ (shouldContain,+ shouldNotContain)+import Test.QuickCheck (Arbitrary (..),+ choose, listOf,+ property)++import Servant.API.ContentTypes+import Servant.API.Internal.Test.ComprehensiveAPI+import Servant.Foreign++import Servant.PY.Internal+++data SomeJson = SomeJson+ { uvalue :: !T.Text+ , pvalue :: !T.Text+ , otherMissing :: Maybe T.Text+ } deriving (Eq, Show, Generic)+instance ToJSON SomeJson++-- * Our API type+type TestApi = "counter-req-header" :> Post '[JSON] SomeJson+ :<|> "counter-queryparam"+ :> QueryParam "sortby" T.Text+ :> Header "Some-Header" T.Text :> Get '[JSON] SomeJson+ :<|> "login-queryflag" :> QueryFlag "published" :> Get '[JSON] SomeJson+ :<|> "login-params-authors-with-reqBody"+ :> QueryParams "authors" T.Text+ :> ReqBody '[JSON] SomeJson :> Post '[JSON] SomeJson++testApi :: Proxy TestApi+testApi = Proxy+++type CaptureApi = "login-with-path-var-and-header"+ :> Capture "id" Int+ :> Capture "Name" T.Text+ :> Capture "hungrig" Bool+ :> ReqBody '[JSON] SomeJson+ :> Post '[JSON] (Headers '[Header "test-head" B.ByteString] SomeJson)+captureApi :: Proxy CaptureApi+captureApi = Proxy++customOptions :: CommonGeneratorOptions+customOptions = defCommonGeneratorOptions+ { urlPrefix = "urlForRequesting:9000"+ , returnMode = RawResponse+ }++spec :: Spec+spec = describe "Servant.PY.Internal" internalSpec++shouldContain :: Text -> Text -> Expectation+a `shouldContain` b = shouldSatisfy a (T.isInfixOf b)++shouldNotContain :: Text -> Text -> Expectation+a `shouldNotContain` b = shouldNotSatisfy a (T.isInfixOf b)++newtype ASCII = ASCII {getASCII :: T.Text} deriving (Show)++instance Arbitrary ASCII where+ -- Our arbitrary instance is generating only ASCII, since the language-ecmascript's lexer+ -- is currently (October 2016) still a bit naïve+ arbitrary = fmap (ASCII . T.pack) $ listOf $ choose (minBound, '\127')+ shrink xs = (ASCII . T.pack) <$> shrink (T.unpack $ getASCII xs)+++internalSpec :: Spec+internalSpec = describe "Internal" $ do+ describe "pure text functions" $ do+ it "should only indent using whitespace" $+ property $ \n -> indenter n indent == mconcat (replicate n (T.pack " "))++ it "should generate a valid python identifier when supplied with hyphens, unicode whitespace, non-bmp unicode" $+ toValidFunctionName "a_--a\66352b\6158c\65075" `shouldBe` "a_abc\65075"++ it "should produce PyDicts where the key is a quoted version of the variable name" $ do+ let dict = toPyDict " " ["forty", "one", "people"]+ dict `shouldBe` "{\"forty\": forty,\n \"one\": one,\n \"people\": people}"++ describe "functions that operate on Req objects" $ do+ let captureList = listFromAPI (Proxy :: Proxy NoTypes) (Proxy :: Proxy NoContent) captureApi+ it "should correctly find captures" $ do+ let captured = captures . head $ captureList+ captured `shouldBe` ["id", "Name", "hungrig"]++ let reqList = listFromAPI (Proxy :: Proxy NoTypes) (Proxy :: Proxy NoContent) testApi+ it "should not incorrectly find captures" $ do+ let captured = captures . head $ reqList+ captured `shouldBe` []++ let req = head captureList+ let pathParts = req ^.. reqUrl.path.traverse+ it "should correctly find captures as a list" $+ capturesToFormatArgs pathParts `shouldBe` ["id", "Name", "hungrig"]++ it "should correctly format captures" $+ withFormattedCaptures " " pathParts `shouldBe` ".format(\n id=parse.quote(str(id)),\n"+ <> " Name=parse.quote(str(Name)),\n "+ <> "hungrig=parse.quote(str(hungrig)))"++ it "should build a formatted val with parse.quote and str" $+ property $ \s -> T.isInfixOf "=parse.quote(str(" $ formatBuilder $ T.pack s+ it "should build a formatted val that ends with parens" $+ property $ \s -> T.isSuffixOf (T.pack s <> "))") $ formatBuilder $ T.pack s++ it "should build urls properly with / separator" $ do+ let pyUrl = makePyUrl customOptions req " "+ pyUrl `shouldBe` "\"urlForRequesting:9000/login-with-path-var-and-header/{id}/{Name}/{hungrig}\""+ <> withFormattedCaptures " " pathParts++ it "should do segment-to-str as a plain string for Static" $+ segmentToStr (head pathParts) == "login-with-path-var-and-header"+ it "should do segment-to-str in formatting braces for a capture" $+ segmentToStr (last pathParts) == "{hungrig}"+ it "should build a doctstring that looks like a regular Python docstring" $ do+ let docstring = buildDocString req customOptions+ docstring `shouldContain` "POST"+ docstring `shouldContain` makePyUrl' pathParts+ docstring `shouldContain` "Args:"+ docstring `shouldContain` "Returns:"
+ test/Servant/PYSpec.hs view
@@ -0,0 +1,61 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TypeOperators #-}+{-# OPTIONS_GHC -fno-warn-orphans #-}++module Servant.PYSpec where++import Data.Either (isRight)+import Data.Monoid ()+import Data.Monoid.Compat ((<>))+import Data.Proxy+import Data.Text (Text)+import qualified Data.Text as T+import GHC.TypeLits+import Prelude ()+import Prelude.Compat+import Test.Hspec hiding+ (shouldContain,+ shouldNotContain)+import Test.QuickCheck (Arbitrary (..),+ choose, listOf,+ property)++import Servant.API.ContentTypes+import Servant.API.Internal.Test.ComprehensiveAPI++import Servant.PY.Internal+++customOptions :: CommonGeneratorOptions+customOptions = defCommonGeneratorOptions+ { urlPrefix = "urlForRequesting:9000"+ , returnMode = DangerMode+ }++spec :: Spec+spec = describe "Servant.PY.Internal" internalSpec++shouldContain :: Text -> Text -> Expectation+a `shouldContain` b = shouldSatisfy a (T.isInfixOf b)++shouldNotContain :: Text -> Text -> Expectation+a `shouldNotContain` b = shouldNotSatisfy a (T.isInfixOf b)++newtype ASCII = ASCII {getASCII :: T.Text} deriving (Show)++instance Arbitrary ASCII where+ -- Our arbitrary instance is generating only ASCII, since the language-ecmascript's lexer+ -- is currently (October 2016) still a bit naïve+ arbitrary = fmap (ASCII . T.pack) $ listOf $ choose (minBound, '\127')+ shrink xs = (ASCII . T.pack) <$> shrink (T.unpack $ getASCII xs)+++internalSpec :: Spec+internalSpec = describe "Internal" $ do+ it "should only indent using whitespace" $+ property $ \n -> indenter n indent == mconcat (replicate n (T.pack " "))
+ test/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}