From 1fef1991ef2257cde7fad5ba355c5e9266d7eb1e Mon Sep 17 00:00:00 2001 From: Jacky Hu Date: Fri, 24 Jun 2016 10:54:34 +0800 Subject: [PATCH] Add proxy awareness for OpenAPI spec generation Proxy uri from command line options takes precedence over host and port --- CHANGELOG.md | 1 + main/Main.hs | 5 ++ postgrest.cabal | 3 + src/PostgREST/App.hs | 8 ++- src/PostgREST/Config.hs | 2 + src/PostgREST/OpenAPI.hs | 111 ++++++++++++++++++++++++++++++---- src/PostgREST/Types.hs | 7 +++ stack.yaml | 1 + test/Feature/ProxySpec.hs | 13 ++++ test/Feature/StructureSpec.hs | 31 ++-------- test/Main.hs | 6 ++ test/SpecHelper.hs | 45 ++++++++++++-- 12 files changed, 188 insertions(+), 45 deletions(-) create mode 100644 test/Feature/ProxySpec.hs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea6aa688..a0c407867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added - Ability to generate an OpenAPI spec - @mainx07, @hudayou, @ruslantalpa, @begriffs +- Ability to generate an OpenAPI spec behind a proxy- @hudayou - Ability to set addresses to listen on - @hudayou - Filtering, shaping and embedding with &select for the /rpc path - @ruslantalpa - Output names of used-defined types (instead of 'USER-DEFINED') - @martingms diff --git a/main/Main.hs b/main/Main.hs index 6fe7b25f8..34fc543e0 100644 --- a/main/Main.hs +++ b/main/Main.hs @@ -8,6 +8,7 @@ import PostgREST.Config (AppConfig (..), minimumPgVersion, prettyVersion, readOptions) +import PostgREST.OpenAPI (isMalformedProxyUri) import PostgREST.DbStructure import Control.Monad @@ -50,12 +51,16 @@ main = do conf <- readOptions let host = configHost conf port = configPort conf + proxy = configProxyUri conf pgSettings = cs (configDatabase conf) appSettings = setHost (fromString host) . setPort port . setServerName (cs $ "postgrest/" <> prettyVersion) $ defaultSettings + when (isMalformedProxyUri proxy) $ error + "Malformed proxy uri, a correct example: https://example.com:8443/basePath" + unless (secret "secret" /= configJwtSecret conf) $ putStrLn "WARNING, running in insecure mode, JWT secret is the default value" Prelude.putStrLn $ "Listening on port " ++ diff --git a/postgrest.cabal b/postgrest.cabal index 72b173d1e..f427820e0 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -67,6 +67,7 @@ executable postgrest , warp >= 3.1.0 , insert-ordered-containers >= 0.1.0.1 , swagger2 >= 2.1 + , network-uri >= 2.6.1.0 , HTTP , Ranged-sets , protolude >= 0.1.5 && < 0.2.0 @@ -115,6 +116,7 @@ library , insert-ordered-containers >= 0.1.0.1 , swagger2 >= 2.1 , protolude >= 0.1.5 && < 0.2.0 + , network-uri >= 2.6.1.0 Other-Modules: Paths_postgrest Exposed-Modules: PostgREST.App @@ -197,5 +199,6 @@ Test-Suite spec , hjsonpointer , hjsonschema , swagger2 + , network-uri , HTTP , Ranged-sets diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 3464cb8b2..cc52db42c 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -14,7 +14,7 @@ import Data.List (find, delete) import Data.Maybe (fromMaybe, fromJust, mapMaybe) import Data.Ranged.Ranges (emptyRange) import Data.String.Conversions (cs) -import Data.Text (Text, replace, strip, isInfixOf, dropWhile, drop) +import Data.Text (Text, replace, strip, pack, isInfixOf, dropWhile, drop) import Data.Tree import qualified Hasql.Pool as P @@ -204,9 +204,13 @@ app dbStructure conf apiRequest = else cs $ encode body) (ActionRead, TargetRoot, Nothing) -> do - let encodeApi ti = encodeOpenAPI ti host port + let encodeApi ti = encodeOpenAPI ti uri' host = configHost conf port = toInteger $ configPort conf + proxy = pickProxy $ configProxyUri conf + uri Nothing = ("http", pack host, port, "/") + uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b) + uri' = uri proxy encodeFn = if contentType == OpenAPI then encodeApi . toTableInfo else encode header = if contentType == OpenAPI then openapiH else jsonH body <- encodeFn <$> H.query schema accessibleTables diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index 3835b342a..d07c1a36d 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -38,6 +38,7 @@ import Web.JWT (Secret, secret) data AppConfig = AppConfig { configDatabase :: String , configAnonRole :: String + , configProxyUri :: Maybe String , configSchema :: String , configHost :: String , configPort :: Int @@ -51,6 +52,7 @@ argParser :: Parser AppConfig argParser = AppConfig <$> argument str (help "(REQUIRED) database connection string, e.g. postgres://user:pass@host:port/db" <> metavar "DB_URL") <*> strOption (long "anonymous" <> short 'a' <> help "(REQUIRED) postgres role to use for non-authenticated requests" <> metavar "ROLE") + <*> (optional . strOption) (long "proxy-uri" <> short 'x' <> help "proxy uri of the HTTP server" <> metavar "PROXY") <*> strOption (long "schema" <> short 's' <> help "schema to use for API routes" <> metavar "NAME" <> value "public" <> showDefault) <*> strOption (long "host" <> short 'l' <> help "hostname or ip on which to run HTTP server" <> metavar "HOST" <> value "*4" <> showDefault) <*> option auto (long "port" <> short 'p' <> help "port number on which to run HTTP server" <> metavar "PORT" <> value 3000 <> showDefault) diff --git a/src/PostgREST/OpenAPI.hs b/src/PostgREST/OpenAPI.hs index dcea58909..cd7136a78 100644 --- a/src/PostgREST/OpenAPI.hs +++ b/src/PostgREST/OpenAPI.hs @@ -2,24 +2,30 @@ module PostgREST.OpenAPI ( encodeOpenAPI + , isMalformedProxyUri + , pickProxy ) where import Control.Lens import Data.Aeson (decode, encode) import Data.ByteString.Lazy (ByteString) import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList) +import Data.Maybe (isJust, isNothing, fromJust) import Data.String (IsString (..)) -import Data.Text (Text, unpack, pack, concat, intercalate) +import Data.Text (Text, unpack, pack, concat, intercalate, init, tail, toLower) import qualified Data.Set as Set +import Network.URI (parseURI, isAbsoluteURI, + URI (..), URIAuth (..)) -import Prelude hiding (concat) +import Prelude hiding (concat, init, tail) import Data.Swagger import PostgREST.ApiRequest (ContentType(..)) import PostgREST.Config (prettyVersion) import PostgREST.QueryBuilder (operators) -import PostgREST.Types (Table(..), Column(..)) +import PostgREST.Types (Table(..), Column(..), + Proxy(..)) makeMimeList :: [ContentType] -> MimeList makeMimeList cs = MimeList $ map (fromString . show) cs @@ -215,7 +221,7 @@ makeRootPathItem = ("/", p) makePathItems :: [(Table, [Column], [Text])] -> InsOrdHashMap FilePath PathItem makePathItems ti = fromList $ makeRootPathItem : map makePathItem ti -escapeHostName :: String -> String +escapeHostName :: Text -> Text escapeHostName "*" = "0.0.0.0" escapeHostName "*4" = "0.0.0.0" escapeHostName "!4" = "0.0.0.0" @@ -223,10 +229,10 @@ escapeHostName "*6" = "0.0.0.0" escapeHostName "!6" = "0.0.0.0" escapeHostName h = h -postgrestSpec:: [(Table, [Column], [Text])] -> String -> Integer -> Swagger -postgrestSpec ti h p = (mempty :: Swagger) - & basePath ?~ "/" - & schemes ?~ [Http] +postgrestSpec:: [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> Swagger +postgrestSpec ti (s, h, p, b) = (mempty :: Swagger) + & basePath ?~ unpack b + & schemes ?~ [s'] & info .~ ((mempty :: Info) & version .~ pack prettyVersion & title .~ "PostgREST API" @@ -235,7 +241,90 @@ postgrestSpec ti h p = (mempty :: Swagger) & definitions .~ makeDefinitions ti & paths .~ makePathItems ti where - h' = Just $ Host (escapeHostName h) (Just (fromInteger p)) + s' = if s == "http" then Http else Https + h' = Just $ Host (unpack $ escapeHostName h) (Just (fromInteger p)) -encodeOpenAPI :: [(Table, [Column], [Text])] -> String -> Integer -> ByteString -encodeOpenAPI ti h p = encode $ postgrestSpec ti h p +encodeOpenAPI :: [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> ByteString +encodeOpenAPI ti uri = encode $ postgrestSpec ti uri + +{-| + Test whether a proxy uri is malformed or not. + A valid proxy uri should be an absolute uri without query and user info, + only http(s) schemes are valid, port number range is 1-65535. + + For example + http://postgrest.com/openapi.json + https://postgrest.com:8080/openapi.json +-} +isMalformedProxyUri :: Maybe String -> Bool +isMalformedProxyUri Nothing = False +isMalformedProxyUri (Just uri) + | isAbsoluteURI uri = not $ isUriValid $ toURI uri + | otherwise = True + +toURI :: String -> URI +toURI uri = fromJust $ parseURI uri + +pickProxy :: Maybe String -> Maybe Proxy +pickProxy proxy + | isNothing proxy = Nothing + -- should never happen + -- since the request would have been rejected by the middleware if proxy uri + -- is malformed + | isMalformedProxyUri proxy = Nothing + | otherwise = Just Proxy { + proxyScheme = scheme + , proxyHost = host' + , proxyPort = port'' + , proxyPath = path' + } + where + uri = toURI $ fromJust proxy + scheme = init $ toLower $ pack $ uriScheme uri + path URI {uriPath = ""} = "/" + path URI {uriPath = p} = p + path' = pack $ path uri + authority = fromJust $ uriAuthority uri + host' = pack $ uriRegName authority + port' = uriPort authority + port'' :: Integer + port'' = case (port', scheme) of + ("", "http") -> 80 + ("", "https") -> 443 + _ -> read $ unpack $ tail $ pack port' + +isUriValid:: URI -> Bool +isUriValid = fAnd [isSchemeValid, isQueryValid, isAuthorityValid] + +fAnd :: [a -> Bool] -> a -> Bool +fAnd fs x = all ($x) fs + +isSchemeValid :: URI -> Bool +isSchemeValid URI {uriScheme = s} + | toLower (pack s) == "https:" = True + | toLower (pack s) == "http:" = True + | otherwise = False + +isQueryValid :: URI -> Bool +isQueryValid URI {uriQuery = ""} = True +isQueryValid _ = False + +isAuthorityValid :: URI -> Bool +isAuthorityValid URI {uriAuthority = a} + | isJust a = fAnd [isUserInfoValid, isHostValid, isPortValid] $ fromJust a + | otherwise = False + +isUserInfoValid :: URIAuth -> Bool +isUserInfoValid URIAuth {uriUserInfo = ""} = True +isUserInfoValid _ = False + +isHostValid :: URIAuth -> Bool +isHostValid URIAuth {uriRegName = ""} = False +isHostValid _ = True + +isPortValid :: URIAuth -> Bool +isPortValid URIAuth {uriPort = ""} = True +isPortValid URIAuth {uriPort = (':':p)} = + let i :: Integer = read p in + i > 0 && i < 65536 +isPortValid _ = False diff --git a/src/PostgREST/Types.hs b/src/PostgREST/Types.hs index 15f87e5d4..3788696a4 100644 --- a/src/PostgREST/Types.hs +++ b/src/PostgREST/Types.hs @@ -103,6 +103,13 @@ data Payload = PayloadJSON UniformObjects | PayloadParseError BS.ByteString deriving (Show, Eq) +data Proxy = Proxy { + proxyScheme :: Text +, proxyHost :: Text +, proxyPort :: Integer +, proxyPath :: Text +} deriving (Show, Eq) + type Operator = Text data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq) type FieldName = Text diff --git a/stack.yaml b/stack.yaml index f3d4233bb..e08b29f88 100644 --- a/stack.yaml +++ b/stack.yaml @@ -18,6 +18,7 @@ extra-deps: - hackage-security-0.5.2.1 - swagger2-2.1 - hjsonpointer-0.3.0.1 + - network-uri-2.6.1.0 ghc-options: postgrest: -O2 -Werror -Wall -fwarn-identities diff --git a/test/Feature/ProxySpec.hs b/test/Feature/ProxySpec.hs new file mode 100644 index 000000000..c66fc9cdd --- /dev/null +++ b/test/Feature/ProxySpec.hs @@ -0,0 +1,13 @@ +module Feature.ProxySpec where + +import Test.Hspec hiding (pendingWith) + +import SpecHelper + +import Network.Wai (Application) + +spec :: SpecWith Application +spec = + describe "GET / with proxy" $ + it "returns a valid openapi spec with proxy" $ + validateOpenApiResponse [("Accept", "application/openapi+json")] diff --git a/test/Feature/StructureSpec.hs b/test/Feature/StructureSpec.hs index f3e75cf45..511bbb1bf 100644 --- a/test/Feature/StructureSpec.hs +++ b/test/Feature/StructureSpec.hs @@ -3,16 +3,12 @@ module Feature.StructureSpec where import Test.Hspec hiding (pendingWith) import Test.Hspec.Wai import Test.Hspec.Wai.JSON +import Network.HTTP.Types import SpecHelper -import Network.HTTP.Types import Network.Wai (Application) -import Network.Wai.Test (SResponse(simpleStatus, simpleHeaders, simpleBody)) - -import Data.Maybe (fromJust) -import Data.Aeson (decode) -import qualified Data.JsonSchema.Draft4 as D4 +import Network.Wai.Test (SResponse(simpleHeaders)) spec :: SpecWith Application spec = do @@ -65,27 +61,8 @@ spec = do ] |] {matchStatus = 200} - it "returns a valid openapi spec" $ do - r <- request methodGet "/" [("Accept", "application/openapi+json")] "" - liftIO $ - let respStatus = simpleStatus r in - respStatus `shouldSatisfy` - \s -> s == Status { statusCode = 200, statusMessage="OK" } - liftIO $ - let respHeaders = simpleHeaders r in - respHeaders `shouldSatisfy` - \hs -> ("Content-Type", "application/openapi+json; charset=utf-8") `elem` hs - liftIO $ - let respBody = simpleBody r - schema :: D4.Schema - schema = D4.emptySchema { D4._schemaRef = Just "openapi.json" } - schemaContext :: D4.SchemaWithURI D4.Schema - schemaContext = D4.SchemaWithURI - { D4._swSchema = schema - , D4._swURI = Just "test/fixtures/openapi.json" - } - in - D4.fetchFilesystemAndValidate schemaContext ((fromJust . decode) respBody) `shouldReturn` Right () + it "returns a valid openapi spec" $ + validateOpenApiResponse [("Accept", "application/openapi+json")] it "should respond to openapi request on none root path with 415" $ request methodGet "/none_root_path" diff --git a/test/Main.hs b/test/Main.hs index 4e0c47efb..7be28d525 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -20,6 +20,7 @@ import qualified Feature.QuerySpec import qualified Feature.RangeSpec import qualified Feature.StructureSpec import qualified Feature.UnicodeSpec +import qualified Feature.ProxySpec main :: IO () main = do @@ -32,6 +33,7 @@ main = do let withApp = return $ postgrest testCfg refDbStructure pool ltdApp = return $ postgrest testLtdRowsCfg refDbStructure pool unicodeApp = return $ postgrest testUnicodeCfg refDbStructure pool + proxyApp = return $ postgrest testProxyCfg refDbStructure pool hspec $ do mapM_ (beforeAll_ resetDb . before withApp) specs @@ -44,6 +46,10 @@ main = do beforeAll_ resetDb . before unicodeApp $ describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec + -- this test runs with a proxy + beforeAll_ resetDb . before proxyApp $ + describe "Feature.ProxySpec" Feature.ProxySpec.spec + where specs = map (uncurry describe) [ ("Feature.AuthSpec" , Feature.AuthSpec.spec) diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs index 9e80f9e26..1c6bc23df 100644 --- a/test/SpecHelper.hs +++ b/test/SpecHelper.hs @@ -3,8 +3,6 @@ module SpecHelper where import Data.String.Conversions (cs) import Control.Monad (void) -import Network.HTTP.Types.Header (Header, ByteRange, renderByteRange, - hRange, hAuthorization, hAccept) import Codec.Binary.Base64.String (encode) import Data.CaseInsensitive (CI(..)) import Text.Regex.TDFA ((=~)) @@ -14,20 +12,57 @@ import Web.JWT (secret) import PostgREST.Config (AppConfig(..)) +import Test.Hspec hiding (pendingWith) +import Test.Hspec.Wai + +import Network.HTTP.Types +import Network.Wai.Test (SResponse(simpleStatus, simpleHeaders, simpleBody)) + +import Data.Maybe (fromJust) +import Data.Aeson (decode) +import qualified Data.JsonSchema.Draft4 as D4 + +validateOpenApiResponse :: [Header] -> WaiSession () +validateOpenApiResponse headers = do + r <- request methodGet "/" headers "" + liftIO $ + let respStatus = simpleStatus r in + respStatus `shouldSatisfy` + \s -> s == Status { statusCode = 200, statusMessage="OK" } + liftIO $ + let respHeaders = simpleHeaders r in + respHeaders `shouldSatisfy` + \hs -> ("Content-Type", "application/openapi+json; charset=utf-8") `elem` hs + liftIO $ + let respBody = simpleBody r + schema :: D4.Schema + schema = D4.emptySchema { D4._schemaRef = Just "openapi.json" } + schemaContext :: D4.SchemaWithURI D4.Schema + schemaContext = D4.SchemaWithURI + { D4._swSchema = schema + , D4._swURI = Just "test/fixtures/openapi.json" + } + in + D4.fetchFilesystemAndValidate schemaContext ((fromJust . decode) respBody) `shouldReturn` Right () + testDbConn :: String testDbConn = "postgres://postgrest_test_authenticator@localhost:5432/postgrest_test" testCfg :: AppConfig testCfg = - AppConfig testDbConn "postgrest_test_anonymous" "test" "localhost" 3000 (secret "safe") 10 Nothing True + AppConfig testDbConn "postgrest_test_anonymous" Nothing "test" "localhost" 3000 (secret "safe") 10 Nothing True testUnicodeCfg :: AppConfig testUnicodeCfg = - AppConfig testDbConn "postgrest_test_anonymous" "تست" "localhost" 3000 (secret "safe") 10 Nothing True + AppConfig testDbConn "postgrest_test_anonymous" Nothing "تست" "localhost" 3000 (secret "safe") 10 Nothing True testLtdRowsCfg :: AppConfig testLtdRowsCfg = - AppConfig testDbConn "postgrest_test_anonymous" "test" "localhost" 3000 (secret "safe") 10 (Just 2) True + AppConfig testDbConn "postgrest_test_anonymous" Nothing "test" "localhost" 3000 (secret "safe") 10 (Just 2) True + +testProxyCfg :: AppConfig +testProxyCfg = + AppConfig testDbConn "postgrest_test_anonymous" (Just "https://postgrest.com/openapi.json") "test" "localhost" 3000 (secret "safe") 10 Nothing True setupDb :: IO () setupDb = do