diff --git a/.circleci/config.yml b/.circleci/config.yml index db565e8e4..50bae41be 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -232,6 +232,7 @@ jobs: psql "postgres:///postgrest_test" -f test/fixtures/roles.sql psql "postgres:///postgrest_test" -f test/fixtures/schema.sql psql "postgres:///postgrest_test" -f test/fixtures/jwt.sql + psql "postgres:///postgrest_test" -f test/fixtures/jsonschema.sql psql "postgres:///postgrest_test" -f test/fixtures/privileges.sql test/memory-tests.sh - save_cache: diff --git a/postgrest.cabal b/postgrest.cabal index 3250672c6..e4771e2d7 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -158,7 +158,6 @@ Test-Suite spec , hasql-pool >= 0.5 && < 0.6 , hasql-transaction >= 0.7 && < 0.8 , heredoc - , hjsonschema == 1.5.0.1 , hspec , hspec-wai >= 0.7.0 , hspec-wai-json @@ -170,6 +169,7 @@ Test-Suite spec , process , protolude == 0.2.2 , regex-tdfa + , text , time , transformers-base , wai diff --git a/stack.yaml b/stack.yaml index 0fe40b5a4..26a97c563 100644 --- a/stack.yaml +++ b/stack.yaml @@ -2,8 +2,6 @@ resolver: lts-9.6 extra-deps: - configurator-ng-0.0.0.1 - critbit-0.2.0.0 - - hjsonpointer-1.1.1 - - hjsonschema-1.5.0.1 - Ranged-sets-0.3.0 - protolude-0.2.2 - hasql-1.3 @@ -18,6 +16,3 @@ ghc-options: postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints nix: shell-file: shell.nix -# only added because of hjsonschema conflict with http-types -# once hjsonschema upper bounding on http-types is solved it can be removed -allow-newer: true diff --git a/test/Feature/ProxySpec.hs b/test/Feature/ProxySpec.hs index dba05af85..2077c71ae 100644 --- a/test/Feature/ProxySpec.hs +++ b/test/Feature/ProxySpec.hs @@ -3,14 +3,11 @@ module Feature.ProxySpec where import Network.Wai (Application) import Test.Hspec hiding (pendingWith) -import Test.Hspec.Wai - import Protolude import SpecHelper spec :: SpecWith Application spec = describe "GET / with proxy" $ - it "returns a valid openapi spec with proxy" $ do - pendingWith "Test timing out frequently on CI, please run locally" + 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 bbae04b70..a140153b1 100644 --- a/test/Feature/StructureSpec.hs +++ b/test/Feature/StructureSpec.hs @@ -21,8 +21,7 @@ spec :: SpecWith Application spec = do describe "OpenAPI" $ do - it "root path returns a valid openapi spec" $ do - pendingWith "Test timing out frequently on CI, please run locally" + it "root path returns a valid openapi spec" $ validateOpenApiResponse [("Accept", "application/openapi+json")] it "should respond to openapi request on none root path with 415" $ diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs index 31b38dcfb..5a4a8bf30 100644 --- a/test/SpecHelper.hs +++ b/test/SpecHelper.hs @@ -5,14 +5,12 @@ import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Lazy as BL import qualified Data.Map.Strict as M import qualified Data.Set as S -import qualified JSONSchema.Draft4 as D4 import qualified System.IO.Error as E import Control.Monad (void) -import Data.Aeson (Value (..), decode) +import Data.Aeson (Value (..), decode, encode) import Data.CaseInsensitive (CI (..)) import Data.List (lookup) -import Data.Maybe (fromJust) import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus)) import System.Environment (getEnv) import System.Process (readProcess) @@ -45,17 +43,19 @@ validateOpenApiResponse headers = do 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 () + let Just body = decode (simpleBody r) + Just schema <- liftIO $ decode <$> BL.readFile "test/fixtures/openapi.json" + let args :: M.Map Text Value + args = M.fromList + [ ( "schema", schema ) + , ( "data", body ) ] + hdrs = acceptHdrs "application/json" + request methodPost "/rpc/validate_json_schema" hdrs (encode args) + `shouldRespondWith` "true" + { matchStatus = 200 + , matchHeaders = [] + } + getEnvVarWithDefault :: Text -> Text -> IO Text getEnvVarWithDefault var def = toS <$> @@ -130,6 +130,7 @@ setupDb dbConn = do loadFixture dbConn "roles" loadFixture dbConn "schema" loadFixture dbConn "jwt" + loadFixture dbConn "jsonschema" loadFixture dbConn "privileges" resetDb dbConn diff --git a/test/fixtures/jsonschema.sql b/test/fixtures/jsonschema.sql new file mode 100644 index 000000000..9b3df794e --- /dev/null +++ b/test/fixtures/jsonschema.sql @@ -0,0 +1,280 @@ +-- from gavinwahl/postgres-json-schema commit 5a257e19a1569a77b82e9182b0b7d9fc8b6f6382 + +/* +Copyright (c) 2016, Gavin Wahl + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose, without fee, and without a written agreement is +hereby granted, provided that the above copyright notice and this paragraph and +the following two paragraphs appear in all copies. + +IN NO EVENT SHALL GAVIN WAHL BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, +SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING +OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF GAVIN WAHL HAS +BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +GAVIN WAHL SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND GAVIN WAHL +HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. +*/ + +CREATE OR REPLACE FUNCTION _validate_json_schema_type(type text, data jsonb) RETURNS boolean AS $f$ +BEGIN + IF type = 'integer' THEN + IF jsonb_typeof(data) != 'number' THEN + RETURN false; + END IF; + IF trunc(data::text::numeric) != data::text::numeric THEN + RETURN false; + END IF; + ELSE + IF type != jsonb_typeof(data) THEN + RETURN false; + END IF; + END IF; + RETURN true; +END; +$f$ LANGUAGE 'plpgsql' IMMUTABLE; + +CREATE OR REPLACE FUNCTION test.validate_json_schema(schema jsonb, data jsonb, root_schema jsonb DEFAULT NULL) RETURNS boolean AS $f$ +DECLARE + prop text; + item jsonb; + path text[]; + types text[]; + pattern text; + props text[]; +BEGIN + IF root_schema IS NULL THEN + root_schema = schema; + END IF; + + IF schema ? 'type' THEN + IF jsonb_typeof(schema->'type') = 'array' THEN + types = ARRAY(SELECT jsonb_array_elements_text(schema->'type')); + ELSE + types = ARRAY[schema->>'type']; + END IF; + IF (SELECT NOT bool_or(public._validate_json_schema_type(type, data)) FROM unnest(types) type) THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'properties' THEN + FOR prop IN SELECT jsonb_object_keys(schema->'properties') LOOP + IF data ? prop AND NOT validate_json_schema(schema->'properties'->prop, data->prop, root_schema) THEN + RETURN false; + END IF; + END LOOP; + END IF; + + IF schema ? 'required' AND jsonb_typeof(data) = 'object' THEN + IF NOT ARRAY(SELECT jsonb_object_keys(data)) @> + ARRAY(SELECT jsonb_array_elements_text(schema->'required')) THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'items' AND jsonb_typeof(data) = 'array' THEN + IF jsonb_typeof(schema->'items') = 'object' THEN + FOR item IN SELECT jsonb_array_elements(data) LOOP + IF NOT validate_json_schema(schema->'items', item, root_schema) THEN + RETURN false; + END IF; + END LOOP; + ELSE + IF NOT ( + SELECT bool_and(i > jsonb_array_length(schema->'items') OR validate_json_schema(schema->'items'->(i::int - 1), elem, root_schema)) + FROM jsonb_array_elements(data) WITH ORDINALITY AS t(elem, i) + ) THEN + RETURN false; + END IF; + END IF; + END IF; + + IF jsonb_typeof(schema->'additionalItems') = 'boolean' and NOT (schema->'additionalItems')::text::boolean AND jsonb_typeof(schema->'items') = 'array' THEN + IF jsonb_array_length(data) > jsonb_array_length(schema->'items') THEN + RETURN false; + END IF; + END IF; + + IF jsonb_typeof(schema->'additionalItems') = 'object' THEN + IF NOT ( + SELECT bool_and(validate_json_schema(schema->'additionalItems', elem, root_schema)) + FROM jsonb_array_elements(data) WITH ORDINALITY AS t(elem, i) + WHERE i > jsonb_array_length(schema->'items') + ) THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'minimum' AND jsonb_typeof(data) = 'number' THEN + IF data::text::numeric < (schema->>'minimum')::numeric THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'maximum' AND jsonb_typeof(data) = 'number' THEN + IF data::text::numeric > (schema->>'maximum')::numeric THEN + RETURN false; + END IF; + END IF; + + IF COALESCE((schema->'exclusiveMinimum')::text::bool, FALSE) THEN + IF data::text::numeric = (schema->>'minimum')::numeric THEN + RETURN false; + END IF; + END IF; + + IF COALESCE((schema->'exclusiveMaximum')::text::bool, FALSE) THEN + IF data::text::numeric = (schema->>'maximum')::numeric THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'anyOf' THEN + IF NOT (SELECT bool_or(validate_json_schema(sub_schema, data, root_schema)) FROM jsonb_array_elements(schema->'anyOf') sub_schema) THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'allOf' THEN + IF NOT (SELECT bool_and(validate_json_schema(sub_schema, data, root_schema)) FROM jsonb_array_elements(schema->'allOf') sub_schema) THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'oneOf' THEN + IF 1 != (SELECT COUNT(*) FROM jsonb_array_elements(schema->'oneOf') sub_schema WHERE validate_json_schema(sub_schema, data, root_schema)) THEN + RETURN false; + END IF; + END IF; + + IF COALESCE((schema->'uniqueItems')::text::boolean, false) THEN + IF (SELECT COUNT(*) FROM jsonb_array_elements(data)) != (SELECT count(DISTINCT val) FROM jsonb_array_elements(data) val) THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'additionalProperties' AND jsonb_typeof(data) = 'object' THEN + props := ARRAY( + SELECT key + FROM jsonb_object_keys(data) key + WHERE key NOT IN (SELECT jsonb_object_keys(schema->'properties')) + AND NOT EXISTS (SELECT * FROM jsonb_object_keys(schema->'patternProperties') pat WHERE key ~ pat) + ); + IF jsonb_typeof(schema->'additionalProperties') = 'boolean' THEN + IF NOT (schema->'additionalProperties')::text::boolean AND jsonb_typeof(data) = 'object' AND NOT props <@ ARRAY(SELECT jsonb_object_keys(schema->'properties')) THEN + RETURN false; + END IF; + ELSEIF NOT ( + SELECT bool_and(validate_json_schema(schema->'additionalProperties', data->key, root_schema)) + FROM unnest(props) key + ) THEN + RETURN false; + END IF; + END IF; + + IF schema ? '$ref' THEN + path := ARRAY( + SELECT regexp_replace(regexp_replace(path_part, '~1', '/'), '~0', '~') + FROM UNNEST(regexp_split_to_array(schema->>'$ref', '/')) path_part + ); + -- ASSERT path[1] = '#', 'only refs anchored at the root are supported'; + IF NOT validate_json_schema(root_schema #> path[2:array_length(path, 1)], data, root_schema) THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'enum' THEN + IF NOT EXISTS (SELECT * FROM jsonb_array_elements(schema->'enum') val WHERE val = data) THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'minLength' AND jsonb_typeof(data) = 'string' THEN + IF char_length(data #>> '{}') < (schema->>'minLength')::numeric THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'maxLength' AND jsonb_typeof(data) = 'string' THEN + IF char_length(data #>> '{}') > (schema->>'maxLength')::numeric THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'not' THEN + IF validate_json_schema(schema->'not', data, root_schema) THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'maxProperties' AND jsonb_typeof(data) = 'object' THEN + IF (SELECT count(*) FROM jsonb_object_keys(data)) > (schema->>'maxProperties')::numeric THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'minProperties' AND jsonb_typeof(data) = 'object' THEN + IF (SELECT count(*) FROM jsonb_object_keys(data)) < (schema->>'minProperties')::numeric THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'maxItems' AND jsonb_typeof(data) = 'array' THEN + IF (SELECT count(*) FROM jsonb_array_elements(data)) > (schema->>'maxItems')::numeric THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'minItems' AND jsonb_typeof(data) = 'array' THEN + IF (SELECT count(*) FROM jsonb_array_elements(data)) < (schema->>'minItems')::numeric THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'dependencies' THEN + FOR prop IN SELECT jsonb_object_keys(schema->'dependencies') LOOP + IF data ? prop THEN + IF jsonb_typeof(schema->'dependencies'->prop) = 'array' THEN + IF NOT (SELECT bool_and(data ? dep) FROM jsonb_array_elements_text(schema->'dependencies'->prop) dep) THEN + RETURN false; + END IF; + ELSE + IF NOT validate_json_schema(schema->'dependencies'->prop, data, root_schema) THEN + RETURN false; + END IF; + END IF; + END IF; + END LOOP; + END IF; + + IF schema ? 'pattern' AND jsonb_typeof(data) = 'string' THEN + IF (data #>> '{}') !~ (schema->>'pattern') THEN + RETURN false; + END IF; + END IF; + + IF schema ? 'patternProperties' AND jsonb_typeof(data) = 'object' THEN + FOR prop IN SELECT jsonb_object_keys(data) LOOP + FOR pattern IN SELECT jsonb_object_keys(schema->'patternProperties') LOOP + RAISE NOTICE 'prop %s, pattern %, schema %', prop, pattern, schema->'patternProperties'->pattern; + IF prop ~ pattern AND NOT validate_json_schema(schema->'patternProperties'->pattern, data->prop, root_schema) THEN + RETURN false; + END IF; + END LOOP; + END LOOP; + END IF; + + IF schema ? 'multipleOf' AND jsonb_typeof(data) = 'number' THEN + IF data::text::numeric % (schema->>'multipleOf')::numeric != 0 THEN + RETURN false; + END IF; + END IF; + + RETURN true; +END; +$f$ LANGUAGE 'plpgsql' IMMUTABLE;