servant-ruby (empty) → 0.1.0.0
raw patch · 6 files changed
+449/−0 lines, 6 filesdep +Cabaldep +basedep +casingsetup-changed
Dependencies added: Cabal, base, casing, doctest, lens, servant-foreign, text
Files
- LICENSE +30/−0
- README.md +3/−0
- Setup.hs +2/−0
- servant-ruby.cabal +37/−0
- src/Servant/Ruby.hs +370/−0
- test/Main.hs +7/−0
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Hardy Jones (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,3 @@+# servant-ruby++Create a Ruby client from a Servant API using `Net::HTTP`.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ servant-ruby.cabal view
@@ -0,0 +1,37 @@+name: servant-ruby+version: 0.1.0.0+synopsis: Generate a Ruby client from a Servant API with Net::HTTP.+description: Generate a Ruby client from a Servant API with Net::HTTP.+homepage: https://github.com/joneshf/servant-ruby#readme+license: BSD3+license-file: LICENSE+author: Hardy Jones+maintainer: jones3.hardy@gmail.com+copyright: 2017 Hardy Jones+category: Servant, Web+build-type: Simple+extra-source-files: README.md+cabal-version: >=1.10++library+ hs-source-dirs: src+ exposed-modules: Servant.Ruby+ build-depends: base >= 4.7 && < 5+ , casing >= 0.1 && < 0.2+ , lens >= 4.15 && < 4.16+ , servant-foreign >= 0.9 && < 0.10+ , text >= 1.2 && < 1.3+ default-language: Haskell2010+ ghc-options: -Wall++test-suite doc-test+ type: exitcode-stdio-1.0+ main-is: Main.hs+ hs-source-dirs: test+ build-depends: base+ , Cabal >= 1.24 && < 1.25+ , doctest >= 0.11 && < 0.12++source-repository head+ type: git+ location: https://github.com/joneshf/servant-ruby
+ src/Servant/Ruby.hs view
@@ -0,0 +1,370 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++{-|+Module: Servant.Ruby+Description: Generate a Ruby client from a Servant API using Net::HTTP.+Copyright: (c) Hardy Jones, 2017+License: BSD3+Maintainer: jones3.hardy@gmail.com+Stability: Experimental++-}++module Servant.Ruby (NameSpace(..), ruby) where++import Control.Lens (filtered, folded, to, view, (^.), (^..))++import Data.Function ((&))+import Data.Monoid ((<>))+import Data.Proxy (Proxy(Proxy))+import Data.Text (Text)+import Data.Text.Encoding (decodeUtf8')++import Servant.Foreign+ ( ArgType(Flag, List, Normal)+ , Foreign+ , GenerateList+ , HasForeign+ , HeaderArg+ , NoContent+ , NoTypes+ , QueryArg+ , Req+ , Segment(Segment)+ , SegmentType(Cap, Static)+ , argName+ , argPath+ , captureArg+ , headerArg+ , isCapture+ , listFromAPI+ , path+ , queryArgName+ , queryArgType+ , queryStr+ , reqBody+ , reqFuncName+ , reqHeaders+ , reqMethod+ , reqUrl+ , snakeCaseL+ , _PathSegment+ )++import Text.Casing (quietSnake)++import qualified Data.Text as T++-- $setup+-- >>> :set -XDataKinds+-- >>> :set -XOverloadedStrings+-- >>> :set -XTypeOperators+-- >>> :m + Servant.API+-- >>> :m + Data.Text.IO++{-|+The namespace for the generated class.+-}+data NameSpace+ = NameSpace+ { moduleNames :: [Text]+ -- ^ The list of namespaces you'd like the class to appear in.+ , className :: Text+ -- ^ The name of the class you'd like the API methods to appear in.+ }++{-|+Generate a Ruby class with methods for the Servant API.++Currently assumes the API accepts and returns JSON.++For example:++>>> Data.Text.IO.putStr $ ruby (NameSpace [] "Baz") (Proxy :: Proxy (Get '[JSON] ()))+require "json"+require "net/http"+require "uri"+<BLANKLINE>+class Baz+ def initialize(origin)+ @origin = URI(origin)+ @http = Net::HTTP.new(@origin.host, @origin.port)+ end+<BLANKLINE>+ def get()+ uri = URI("#{origin}")+<BLANKLINE>+ req = Net::HTTP::Get.new(uri)+ req["Accept"] = "application/json"+<BLANKLINE>+ request(req)+ end+<BLANKLINE>+ private+<BLANKLINE>+ def request(req, body = nil)+ res = http.request(req, body)+ res.body = JSON.parse(res.body, symbolize_names: true)+ res+ end+end++The class can be nested in a module namespace if you choose so.++>>> Data.Text.IO.putStr $ ruby (NameSpace ["Foo", "Bar"] "Baz") (Proxy :: Proxy (Get '[JSON] ()))+require "json"+require "net/http"+require "uri"+<BLANKLINE>+module Foo+ module Bar+ class Baz+ def initialize(origin)+ @origin = URI(origin)+ @http = Net::HTTP.new(@origin.host, @origin.port)+ end+<BLANKLINE>+ def get()+ uri = URI("#{origin}")+<BLANKLINE>+ req = Net::HTTP::Get.new(uri)+ req["Accept"] = "application/json"+<BLANKLINE>+ request(req)+ end+<BLANKLINE>+ private+<BLANKLINE>+ def request(req, body = nil)+ res = http.request(req, body)+ res.body = JSON.parse(res.body, symbolize_names: true)+ res+ end+ end+ end+end++Captures and query parameters are translated into required arguments, in that order.++The request body and headers are translated into keyword arguments, in that order.++>>> let api = Proxy :: Proxy ("foo" :> Capture "fooId" Int :> ReqBody '[JSON] () :> QueryParam "bar" Bool :> Header "Max-Forwards" Int :> Post '[JSON] ())+>>> Data.Text.IO.putStr $ ruby (NameSpace [] "Foo") api+require "json"+require "net/http"+require "uri"+<BLANKLINE>+class Foo+ def initialize(origin)+ @origin = URI(origin)+ @http = Net::HTTP.new(@origin.host, @origin.port)+ end+<BLANKLINE>+ def post_foo_by_foo_id(foo_id, bar, body:, max_forwards:)+ uri = URI("#{origin}/foo/#{fooId}?bar=#{bar}")+<BLANKLINE>+ req = Net::HTTP::Post.new(uri)+ req["Accept"] = "application/json"+ req["Content-Type"] = "application/json"+ req["Max-Forwards"] = max_forwards+<BLANKLINE>+ request(req, body)+ end+<BLANKLINE>+ private+<BLANKLINE>+ def request(req, body = nil)+ res = http.request(req, body)+ res.body = JSON.parse(res.body, symbolize_names: true)+ res+ end+end+-}+ruby+ :: (GenerateList NoContent (Foreign NoContent api), HasForeign NoTypes NoContent api)+ => NameSpace+ -> Proxy api+ -> Text+ruby (NameSpace {..}) p =+ T.unlines $+ imports+ ++ prologue+ ++ body+ ++ epilogue+ where+ body =+ initialize indent+ ++ foldMap (public indent) api+ ++ private indent+ api =+ listFromAPI (Proxy :: Proxy NoTypes) (Proxy :: Proxy NoContent) p+ prologue =+ ((\(i, n) -> T.replicate i " " <> "module " <> n) <$> zip [0..] moduleNames)+ ++ [T.replicate indent " " <> "class " <> className]+ epilogue =+ reverse $ (\i -> T.replicate i " " <> "end") <$> [0..indent]+ indent =+ length moduleNames++imports :: [Text]+imports =+ [ "require \"json\""+ , "require \"net/http\""+ , "require \"uri\""+ , ""+ ]++properIndent :: Functor f => Int -> f (Maybe Text) -> f Text+properIndent indent =+ fmap (maybe "" (T.replicate (indent + 1) " " <>))++initialize :: Int -> [Text]+initialize indent =+ properIndent indent+ [ Just "def initialize(origin)"+ , Just " @origin = URI(origin)"+ , Just " @http = Net::HTTP.new(@origin.host, @origin.port)"+ , Just "end"+ ]++private :: Int -> [Text]+private indent =+ properIndent indent+ [ Nothing+ , Just "private"+ , Nothing+ , Just "def request(req, body = nil)"+ , Just " res = http.request(req, body)"+ , Just " res.body = JSON.parse(res.body, symbolize_names: true)"+ , Just " res"+ , Just "end"+ ]++public :: Int -> Req NoContent -> [Text]+public indent req =+ properIndent indent $+ [ Nothing+ , Just $ "def " <> functionName <> "(" <> argsStr <> ")"+ , Just $ " uri = URI(" <> url <> ")"+ , Nothing+ , Just $ " req = Net::HTTP::" <> method <> ".new(uri)"+ ]+ ++ requestHeaders+ +++ [ Nothing+ , Just $ " request" <> request+ , Just "end"+ ]+ where+ functionName :: Text+ functionName = req ^. reqFuncName.snakeCaseL.to snake++ argsStr :: Text+ argsStr = T.intercalate ", " $ snake <$> args++ args :: [Text]+ args =+ captures+ ++ ((^. queryArgName.argPath) <$> queryparams)+ ++ body+ ++ headerArgs++ segments :: [Segment NoContent]+ segments = req ^. reqUrl.path^..folded.filtered isCapture++ queryparams :: [QueryArg NoContent]+ queryparams = req ^.. reqUrl.queryStr.traverse++ captures :: [Text]+ captures = view argPath . captureArg <$> segments++ body :: [Text]+ body =+ case req ^. reqBody of+ Just _ -> ["body:"]+ Nothing -> []++ request :: Text+ request =+ case req ^. reqBody of+ Just _ -> "(req, body)"+ Nothing -> "(req)"++ accept :: [Maybe Text]+ accept = [Just " req[\"Accept\"] = \"application/json\""]++ contentType :: [Maybe Text]+ contentType =+ case req ^. reqBody of+ Just _ -> [Just " req[\"Content-Type\"] = \"application/json\""]+ Nothing -> []++ hs :: [HeaderArg NoContent]+ hs = req ^. reqHeaders++ requestHeaders :: [Maybe Text]+ requestHeaders =+ accept+ ++ contentType+ ++ (requestHeader <$> rawHeaders)++ headerArgs :: [Text]+ headerArgs = (<> ":") <$> rawHeaders++ rawHeaders :: [Text]+ rawHeaders = (^. headerArg.argName._PathSegment) <$> hs++ method :: Text+ method =+ case req ^. reqMethod.to decodeUtf8' of+ Right m -> T.toTitle m+ Left _ -> "Get"++ url :: Text+ url = "\"#{origin}" <> urlArgs <> queryArgs <> "\""++ urlArgs :: Text+ urlArgs = req ^.. reqUrl.path.traverse & rbSegments++ queryArgs :: Text+ queryArgs =+ if null queryparams then+ ""+ else+ "?" <> rbParams "&" queryparams++requestHeader :: Text -> Maybe Text+requestHeader header = Just $ " req[\"" <> header <> "\"] = " <> snake header++rbSegments :: [Segment f] -> Text+rbSegments [] = ""+rbSegments [x] = "/" <> segmentToStr x+rbSegments (x:xs) = "/" <> segmentToStr x <> rbSegments xs++segmentToStr :: Segment f -> Text+segmentToStr (Segment st) = segmentTypeToStr st++segmentTypeToStr :: SegmentType f -> Text+segmentTypeToStr (Static s) = s ^. _PathSegment+segmentTypeToStr (Cap s) = "#{" <> s ^. argName._PathSegment <> "}"++rbParams :: Text -> [QueryArg f] -> Text+rbParams _ [] = ""+rbParams _ [x] = paramToStr x+rbParams s (x:xs) = paramToStr x <> s <> rbParams s xs++paramToStr :: QueryArg f -> Text+paramToStr qarg =+ case qarg ^. queryArgType of+ Normal -> key <> "=#{" <> val <> "}"+ Flag -> key+ List -> key <> "[]=#{" <> val <> "}"+ where+ key = qarg ^. queryArgName.argName._PathSegment+ val = snake key++snake :: Text -> Text+snake = T.pack . quietSnake . T.unpack
+ test/Main.hs view
@@ -0,0 +1,7 @@+module Main where++import Test.DocTest (doctest)++main :: IO ()+main =+ doctest ["-isrc", "src"]