├── integration ├── index.ts ├── firebase.json ├── tsconfig.json ├── wards │ ├── simple.ward │ ├── optionalTypes.ward │ ├── logic.ward │ ├── validations.ward │ ├── expressions.ward │ ├── arraysAndTuples.ward │ ├── paths.ward │ ├── const.ward │ ├── expressions.ts │ ├── paths.ts │ ├── validations.ts │ ├── simple.ts │ ├── logic.ts │ ├── optionalTypes.ts │ ├── primitiveTypes.ward │ ├── const.ts │ ├── arraysAndTuples.ts │ └── primitiveTypes.ts ├── package.json ├── readme.md ├── default.rules ├── test │ ├── paths.spec.ts │ ├── simple.spec.ts │ ├── logic.spec.ts │ ├── optionalTypes.spec.ts │ ├── expressions.spec.ts │ ├── arraysAndTuples.spec.ts │ ├── validations.spec.ts │ ├── const.spec.ts │ └── primitiveTypes.spec.ts └── util │ ├── rules.ts │ └── emulator.ts ├── Setup.hs ├── test ├── Spec.hs ├── fixtures │ ├── indent.ward │ └── indent.rules ├── OptionParserSpec.hs ├── RuleLangSpec.hs ├── LocSpec.hs ├── LogicPrinterSpec.hs ├── TSGeneratorSpec.hs ├── ParserSpec.hs ├── ExprParserSpec.hs ├── RuleGeneratorSpec.hs └── RuleParserSpec.hs ├── examples ├── array.ward ├── integ.ward ├── small.ward ├── map.ward ├── indent.ward ├── simple.ward ├── const.ward └── complete.ward ├── npm-bin ├── .npmignore ├── package-lock.json ├── publish.sh ├── index.js ├── dl-releases.sh └── package.json ├── project.vim ├── src ├── Error.hs ├── Loc.hs ├── LogicPrinter.hs ├── ExprPrinter.hs ├── RuleLang.hs ├── CodePrinter.hs ├── OptionParser.hs ├── Combinators.hs ├── TSGenerator.hs ├── Parser.hs ├── ExprParser.hs ├── RulePrinter.hs ├── RuleParser.hs └── RuleGenerator.hs ├── .opensource └── project.json ├── .gitignore ├── Dockerfile ├── stack.yaml.lock ├── travis ├── buildfile.sh └── getstack.sh ├── SECURITY.md ├── LICENSE ├── app └── Main.hs ├── changelog.md ├── makefile ├── fireward.cabal ├── .travis.yml ├── .circleci └── config.yml ├── stack.yaml └── .github └── workflows └── main.yml /integration/index.ts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /test/Spec.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -F -pgmF hspec-discover #-} 2 | -------------------------------------------------------------------------------- /examples/array.ward: -------------------------------------------------------------------------------- 1 | type X = {a: string[]} 2 | match /test1/{a=**} is X { 3 | } 4 | -------------------------------------------------------------------------------- /examples/integ.ward: -------------------------------------------------------------------------------- 1 | type X = {a: string} 2 | match /test1/{a=**} is X { 3 | } 4 | -------------------------------------------------------------------------------- /npm-bin/.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.swp 3 | *.swo 4 | dl-releases.sh 5 | publish.sh 6 | -------------------------------------------------------------------------------- /project.vim: -------------------------------------------------------------------------------- 1 | " let g:ctrlp_custom_ignore['dir'] = g:ctrlp_custom_ignore['dir'] . '|tmp' 2 | -------------------------------------------------------------------------------- /examples/small.ward: -------------------------------------------------------------------------------- 1 | type X = {a: string} 2 | match /x/{id} is X { 3 | allow write: if id=="123"; 4 | } 5 | -------------------------------------------------------------------------------- /npm-bin/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fireward", 3 | "version": "2.0.19", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /examples/map.ward: -------------------------------------------------------------------------------- 1 | type User = { 2 | permissions: map 3 | } 4 | 5 | match /users/{id} is User { 6 | allow write: true 7 | } 8 | -------------------------------------------------------------------------------- /examples/indent.ward: -------------------------------------------------------------------------------- 1 | type X = int | string | { 2 | a: string 3 | b: { 4 | aa: int|string 5 | bb: string | int | float | {n:string} 6 | } | float 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/indent.ward: -------------------------------------------------------------------------------- 1 | type X = int | string | { 2 | a: string 3 | b: { 4 | aa: int|string 5 | bb: string | int | float | {n:string} 6 | } | float 7 | } 8 | -------------------------------------------------------------------------------- /src/Error.hs: -------------------------------------------------------------------------------- 1 | module Error 2 | ( Error (..) 3 | ) where 4 | 5 | 6 | import Loc (Loc) 7 | data Error = Error (Maybe Loc) String 8 | deriving (Show, Eq) 9 | 10 | -------------------------------------------------------------------------------- /integration/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "default.rules" 4 | }, 5 | "emulators": { 6 | "firestore": { 7 | "port": "8981" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /integration/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "resolveJsonModule": true 5 | }, 6 | 7 | "include": ["/**/*.ts"], 8 | "exclude": ["node_modules", ".git"] 9 | } -------------------------------------------------------------------------------- /examples/simple.ward: -------------------------------------------------------------------------------- 1 | type Name = { first: string, last?: string } 2 | type User = { name: Name | string } 3 | 4 | match /users/{id} is User { 5 | allow read: true; 6 | allow write: request.auth != null; 7 | } 8 | -------------------------------------------------------------------------------- /.opensource/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Fireward", 3 | "platforms": [ 4 | "Web", 5 | "iOS", 6 | "Android" 7 | ], 8 | "content": "readme.md", 9 | "related": [ ] 10 | } 11 | -------------------------------------------------------------------------------- /examples/const.ward: -------------------------------------------------------------------------------- 1 | type User = { 2 | id: const string, 3 | name: const Name // will currently do nothing 4 | } 5 | type Name = { 6 | last: const string, 7 | first: string 8 | } 9 | 10 | match /user/{id} is User { 11 | allow write: if true; 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | /dist 4 | /tmp 5 | /.stack-work 6 | .DS_Store 7 | /fireward 8 | npm-bin/fireward-linux 9 | npm-bin/fireward-osx 10 | npm-bin/fireward.exe 11 | node_modules 12 | .vscode 13 | integration/firebase-debug.log 14 | integration/firestore-debug.log 15 | -------------------------------------------------------------------------------- /integration/wards/simple.ward: -------------------------------------------------------------------------------- 1 | type Name = { first: string, last?: string } 2 | type User = { name: Name | string } 3 | 4 | match /users/{userId} is User { 5 | allow read: true; 6 | allow create: true; 7 | allow update: request.auth.uid!=null && request.auth.uid==userId; 8 | } 9 | -------------------------------------------------------------------------------- /integration/wards/optionalTypes.ward: -------------------------------------------------------------------------------- 1 | 2 | type Name = { first: string, last?: string } 3 | 4 | type OptionalTypesExample = { 5 | str?: string, 6 | num?: float, 7 | sub?: Name 8 | } 9 | 10 | match /example/{x} is OptionalTypesExample { 11 | allow read: true; 12 | allow write: true; 13 | } 14 | -------------------------------------------------------------------------------- /integration/wards/logic.ward: -------------------------------------------------------------------------------- 1 | type Name = { first: string, last?: string } 2 | type User = { name: Name | string } 3 | 4 | match /users/{userId} is User { 5 | allow read: true; 6 | allow create: true; 7 | allow update: request.auth!=null && request.auth.uid==userId; 8 | } 9 | 10 | match /dir/{userId} { 11 | allow read 12 | allow write: false; 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM haskell:8.6 2 | 3 | RUN mkdir -p /opt/src/fireward 4 | 5 | RUN apt-get update 6 | RUN apt-get install -q --assume-yes git 7 | RUN git clone https://github.com/bijoutrouvaille/fireward.git /opt/src/fireward 8 | 9 | WORKDIR /opt/src/fireward 10 | 11 | RUN stack install 12 | 13 | ENTRYPOINT ["/root/.local/bin/fireward"] 14 | CMD ["--lang=rules"] 15 | -------------------------------------------------------------------------------- /integration/wards/validations.ward: -------------------------------------------------------------------------------- 1 | type Pilot = { 2 | experience: float 3 | name: string 4 | allow create, update: if data.name != 'Otto' && data.experience > 3 && (prev==null || prev.experience <= data.experience) 5 | allow delete: data.name != 'Capt. Clarence Oveur' 6 | } 7 | 8 | match /pilots/{id} is Pilot { 9 | allow write: true 10 | allow read: true 11 | } -------------------------------------------------------------------------------- /test/OptionParserSpec.hs: -------------------------------------------------------------------------------- 1 | module OptionParserSpec (main, spec) where 2 | 3 | import Data.Char (isDigit) 4 | import Control.Applicative 5 | import Control.Monad 6 | import Test.Hspec 7 | import Test.QuickCheck 8 | import Debug.Trace (trace) 9 | 10 | main :: IO () 11 | main = hspec spec 12 | 13 | spec :: Spec 14 | spec = do 15 | describe "Option Parser" $ do 16 | it "passes" $ 17 | shouldBe 1 1 18 | 19 | -------------------------------------------------------------------------------- /stack.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: [] 7 | snapshots: 8 | - completed: 9 | size: 500539 10 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/13/27.yaml 11 | sha256: 690db832392afe55733b4c7023fd29b1b1c660ee42f1fb505b86b07394ca994e 12 | original: lts-13.27 13 | -------------------------------------------------------------------------------- /npm-bin/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ev 4 | branch=$(git branch --show-current) 5 | if [ "$1" != "beta" ] && [ $branch != "master" ]; then 6 | echo non-beta releases must be published from the master branch. 7 | exit 1 8 | fi 9 | ./dl-releases.sh 10 | # v=$(./fireward-osx -V) 11 | v=$(git describe --tags `git rev-list --tags --max-count=1`) 12 | cp ../readme.md ./readme.md 13 | npm version "$v" 14 | if [ "$1" = "beta" ]; then 15 | npm publish --tag beta 16 | else 17 | npm publish 18 | fi 19 | npm publish 20 | rm ./readme.md 21 | -------------------------------------------------------------------------------- /integration/wards/expressions.ward: -------------------------------------------------------------------------------- 1 | match /a/{x} { 2 | allow write: if 1==1 || 2==2; 3 | } 4 | match /b/{x} { 5 | allow write: if "a"!="a" || "b"!="b"; 6 | } 7 | match /c/{x} is {test: string} { 8 | allow write: if request.resource.data.test=="f" && 1==1; 9 | } 10 | match /d/{x} is {test: string} { 11 | allow write: if [1,2,3][1]==2; 12 | } 13 | function z(v) { 14 | return v=='hello'; 15 | } 16 | match /e/{x} { 17 | allow write: if 1+2==3 && z(x); 18 | } 19 | 20 | match /f/{x} { 21 | allow write: if 3+2==5 && x.matches('^he..o'); 22 | } 23 | -------------------------------------------------------------------------------- /travis/buildfile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | stack --no-terminal install --bench --no-run-benchmarks 4 | if [ "$TRAVIS_OS_NAME" = "windows" ]; then 5 | # cp "$(stack path --local-bin)\\fireward.exe" "$(stack path --local-bin)\\${DEPLOY_FILE}" 6 | export DEPLOY_FILE=$(stack path --local-bin)\\fireward.exe 7 | # cp "$(stack path --local-bin)\\fireward" "$DEPLOY_FILE" # don't copy anything; it's already there 8 | else 9 | export DEPLOY_FILE=$(stack path --local-bin)/fireward-${TRAVIS_OS_NAME} 10 | cp "$(stack path --local-bin)/fireward" "$DEPLOY_FILE" 11 | fi 12 | -------------------------------------------------------------------------------- /test/RuleLangSpec.hs: -------------------------------------------------------------------------------- 1 | module RuleLangSpec (main, spec) where 2 | 3 | import RuleLang 4 | import CodePrinter 5 | import Error 6 | 7 | import Data.Char (isDigit) 8 | import Control.Applicative 9 | import Control.Monad 10 | import Control.Monad.IO.Class (liftIO) 11 | import Test.Hspec 12 | import Test.QuickCheck 13 | import Debug.Trace (trace) 14 | 15 | main :: IO () 16 | main = hspec spec 17 | 18 | spec :: Spec 19 | spec = do 20 | describe "Rule Language Printer" $ do 21 | it "prints a line" $ do 22 | printCode 0 (_blank >> _indent >> _return) `shouldBe` "\n " 23 | -------------------------------------------------------------------------------- /integration/wards/arraysAndTuples.ward: -------------------------------------------------------------------------------- 1 | type Tuple1 = [string, 3, 'hello', {a: 1}?] 2 | type A = {test: Tuple1} 3 | 4 | match /a/{x} is A { 5 | allow read, write: true 6 | } 7 | 8 | type B = { 9 | test: [float, float, float?, float?] 10 | } 11 | match /b/{x} is B { 12 | allow read, write: true; 13 | } 14 | type Test1 = { 15 | a: 1 16 | } 17 | type C = { 18 | test: Test1[] 19 | } 20 | 21 | match /c/{x} is C { 22 | allow read, write: true; 23 | } 24 | 25 | // Issue 18 26 | type Session = { 27 | dayOfWeek: int 28 | } 29 | type Booking = { 30 | sessions: [Session, Session?] 31 | } 32 | match /book/{id} is Booking { 33 | allow read, write: true; 34 | } -------------------------------------------------------------------------------- /npm-bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var spawn = require('child_process').spawn; 4 | 5 | var path = require('path'); 6 | 7 | const bin = 8 | process.platform === 'darwin' 9 | ? path.join(__dirname, 'fireward-osx') : 10 | process.platform === 'linux' && process.arch === 'x64' 11 | ? path.join(__dirname, 'fireward-linux') : 12 | process.platform === 'win32' && process.arch === 'x64' 13 | ? path.join(__dirname, 'fireward.exe') : 14 | null; 15 | 16 | 17 | var input = process.argv.slice(2); 18 | 19 | if (bin !== null) { 20 | spawn(bin, input, {stdio: 'inherit'}) 21 | .on('exit', process.exit); 22 | } else { 23 | throw new Error('Platform not supported.'); 24 | } 25 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /test/fixtures/indent.rules: -------------------------------------------------------------------------------- 1 | function is____X(data, prev) { 2 | return data is int 3 | || data is string 4 | || data.keys().hasAll(['a', 'b']) 5 | && data.keys().hasOnly(['a', 'b']) 6 | && data['a'] is string 7 | && ( 8 | data['b'].keys().hasAll(['aa', 'bb']) 9 | && data['b'].keys().hasOnly(['aa', 'bb']) 10 | && ( 11 | data['b']['aa'] is int 12 | || data['b']['aa'] is string 13 | ) 14 | && ( 15 | data['b']['bb'] is string 16 | || data['b']['bb'] is int 17 | || (data['b']['bb'] is float || data['b']['bb'] is int) 18 | || data['b']['bb'].keys().hasAll(['n']) 19 | && data['b']['bb'].keys().hasOnly(['n']) 20 | && data['b']['bb']['n'] is string 21 | ) 22 | || (data['b'] is float || data['b'] is int) 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /integration/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fireward-integration-tests", 3 | "version": "1.0.0", 4 | "description": "Using the firestore simulator to test generated rules.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "firebase emulators:exec \"ts-mocha 'test/**/*.spec.ts' -w --watch-extensions ts,ward\"", 8 | "setup": "npm install; firebase setup:emulators:firestore" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@firebase/rules-unit-testing": "2.0.1", 14 | "@types/chai": "^4.2.3", 15 | "@types/mocha": "^5.2.7", 16 | "chai": "^4.2.0", 17 | "firebase": "9.1.3", 18 | "firebase-tools": "9.20.0", 19 | "mocha": "^6.2.1", 20 | "ts-mocha": "8.0.0", 21 | "ts-node": "10.3.0", 22 | "typescript": "^3.7.0-beta" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /npm-bin/dl-releases.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ev 3 | 4 | # v=$(cd .. && stack exec fireward -- -V) 5 | v=$(git describe --tags `git rev-list --tags --max-count=1`) 6 | export files=(fireward-linux fireward-osx fireward.exe) 7 | for f in ${files[*]}; do 8 | if [ -f "$f" ]; then 9 | rm $f 10 | fi 11 | done 12 | for f in ${files[*]}; do 13 | echo "downloading $f" 14 | curl -L https://github.com/bijoutrouvaille/fireward/releases/download/$v/$f -o $f 15 | chmod 755 $f 16 | done 17 | 18 | # curl -L https://github.com/bijoutrouvaille/fireward/releases/download/$v/fireward-linux -o fireward-linux 19 | # curl -L https://github.com/bijoutrouvaille/fireward/releases/download/$v/fireward-osx -o fireward-osx 20 | # curl -L https://github.com/bijoutrouvaille/fireward/releases/download/$v/fireward.exe -o fireward.exe 21 | 22 | -------------------------------------------------------------------------------- /npm-bin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fireward", 3 | "version": "2.0.19", 4 | "description": "A simple and readable language for Firestore security rules, similar to Firebase Bolt.", 5 | "bin": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/bijoutrouvaille/fireward.git" 12 | }, 13 | "keywords": [ 14 | "firebase", 15 | "firestore", 16 | "firestore", 17 | "security", 18 | "firestore", 19 | "rules", 20 | "language" 21 | ], 22 | "author": "Bijou Trouvaille", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/bijoutrouvaille/fireward/issues" 26 | }, 27 | "homepage": "https://github.com/bijoutrouvaille/fireward#readme" 28 | } 29 | -------------------------------------------------------------------------------- /src/Loc.hs: -------------------------------------------------------------------------------- 1 | module Loc 2 | ( loc 3 | , Loc (..) 4 | ) where 5 | 6 | data Loc = Loc Int String 7 | deriving (Show, Eq) 8 | 9 | loc :: String -> String -> Maybe Loc 10 | loc source remainder = result 11 | where 12 | result :: Maybe Loc 13 | result = lineDiff >>= colDiff 14 | lineDiff :: Maybe (String, [String], Int) 15 | lineDiff = diff (lines source) (lines remainder) 16 | colDiff :: (String, [String], Int) -> Maybe Loc 17 | colDiff (head, tail, n) = Just $ Loc n head 18 | 19 | diff :: Eq a => [a] -> [a] -> Maybe (a,[a], Int) 20 | diff s r = result where 21 | result = if s==r then Nothing else next s r 0 22 | next [] r n = Nothing 23 | next (s:ss) [] n = Just (s, ss, n) 24 | next (s:ss) (r:rr) n = if s/=r && ss/=rr 25 | then next ss (r:rr) (n+1) 26 | else Just (s, ss, n) 27 | -------------------------------------------------------------------------------- /integration/readme.md: -------------------------------------------------------------------------------- 1 | # Integration Tests for Fireward 2 | 3 | ## Overview 4 | 5 | - NodeJS + NPM 6 | - Typescript 7 | - [firestore emulator](https://firebase.google.com/docs/firestore/security/test-rules-emulator). 8 | - Mocha + ts-mocha 9 | 10 | ## Preparation 11 | 12 | Having `NodeJS` and the `stack` tool installed, you'll need to first set up the emulator: 13 | 14 | `npm setup` 15 | 16 | ## Running tests 17 | 18 | `npm test` 19 | 20 | ## Directory Structure 21 | 22 | - `test`: actual test files, named .spec.ts 23 | - `wards`: .ward files to test. Emulator runner will generate the .ts files there, which you should commit to git. 24 | - `util`: utility functions. 25 | 26 | ## Writing Tests 27 | 28 | - Copy simple.spec.ts into .spec.ts. 29 | - Create .ward in the `wards` directory. 30 | - Search+replace `simple` in simple.spec.ts with 31 | - write your tests 32 | 33 | 34 | -------------------------------------------------------------------------------- /integration/wards/paths.ward: -------------------------------------------------------------------------------- 1 | rules_version = '2' // optional, see https://firebase.google.com/docs/firestore/security/get-started#security_rules_version_2 2 | 3 | function q(a) { return 'qq' } 4 | 5 | match /refz/{x} { 6 | allow write; 7 | allow read; 8 | } 9 | match /exists/{x} { 10 | allow write: if exists(/databases/$(database)/documents/$(x)/$(q(x))) 11 | } 12 | match /paths/{x} { 13 | allow read: if exists(/test/$(a)/$(b())/$(b(a))) 14 | } 15 | match /test/test { 16 | allow read: if data().keys().hasAll(['a', 'b', 'c']); 17 | } 18 | 19 | function testFalseFunc (a,b) { 20 | let q = false; 21 | return q; 22 | } 23 | function testTrueFunc (a,b) { 24 | let q = true; 25 | return q; 26 | } 27 | 28 | match /testTrueFunc/{x} { 29 | allow read: if testTrueFunc(true, 'asdf'); 30 | allow write: if true; 31 | } 32 | match /testFalseFunc/{x} { 33 | allow read: if testFalseFunc(true, 'asdf'); 34 | allow write: if true; 35 | } 36 | -------------------------------------------------------------------------------- /integration/wards/const.ward: -------------------------------------------------------------------------------- 1 | type StrTest = { 2 | test: const string 3 | name: string 4 | } 5 | type IntTest = { 6 | test: const int 7 | name: string 8 | } 9 | type FloatTest = { 10 | test: const float 11 | name: string 12 | } 13 | type BoolTest = { 14 | test: const bool 15 | name: string 16 | } 17 | type MapTest = { 18 | test: const map 19 | name: string 20 | } 21 | type OptTest = { 22 | test?: const string 23 | name: string 24 | } 25 | match /str/{x} is StrTest { 26 | allow read: true; 27 | allow write: true; 28 | } 29 | match /int/{x} is IntTest { 30 | allow read: true; 31 | allow write: true; 32 | } 33 | match /float/{x} is FloatTest { 34 | allow read: true; 35 | allow write: true; 36 | } 37 | match /bool/{x} is BoolTest { 38 | allow read: true; 39 | allow write: true; 40 | } 41 | match /map/{x} is MapTest { 42 | allow read: true; 43 | allow write: true; 44 | } 45 | match /opt/{x} is OptTest { 46 | allow read: true; 47 | allow write: true; 48 | } -------------------------------------------------------------------------------- /integration/default.rules: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | service cloud.firestore { 5 | match /databases/{database}/documents { 6 | 7 | function isName(data, prev) { 8 | return data.keys().hasAll(['first']) 9 | && data.size() >= 1 10 | && data.size() <= 2 11 | && data.first is string 12 | && ( 13 | !data.keys().hasAny(['last']) 14 | || data.last is string 15 | ); 16 | } 17 | function isUser(data, prev) { 18 | return data.keys().hasAll(['name']) 19 | && data.size() >= 1 20 | && data.size() <= 1 21 | && ( 22 | (prev==null && isName(data.name, null) || isName(data.name, prev)) 23 | || data.name is string 24 | ); 25 | } 26 | match /users/{id} { 27 | function is__PathType(data, prev) { 28 | return (prev==null && isUser(data, null) || isUser(data, prev)); 29 | } 30 | allow read: if true; 31 | allow write: if (resource==null && is__PathType(request.resource.data, null) || is__PathType(request.resource.data, resource.data)) && (false); 32 | } 33 | 34 | } 35 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 github@bijoutrouvaille 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | module Main where 2 | 3 | 4 | import qualified RuleGenerator 5 | import qualified TSGenerator 6 | 7 | import OptionParser (getOptions, Options(..)) 8 | 9 | import System.IO 10 | ( IO(..) 11 | , readFile 12 | , writeFile 13 | , hPutStrLn 14 | , stderr 15 | , stdin 16 | ) 17 | import System.Exit (exitWith, ExitCode(..)) 18 | import Data.List (intercalate) 19 | import Data.Char (isSpace) 20 | 21 | 22 | out output (Left e) = hPutStrLn stderr e 23 | out output (Right v) = output v 24 | 25 | generate lang 26 | | lang == "typescript" = TSGenerator.generate 27 | | lang == "ts" = TSGenerator.generate 28 | | lang == "rules" = RuleGenerator.generate True 29 | | otherwise = const . Left $ 30 | "Specified language \""++lang++"\"not recognized." 31 | 32 | main :: IO () 33 | main = do 34 | (opts, actions, nonOptions, errors) <- getOptions 35 | let Options { optInput = input 36 | , optOutput = output 37 | , optVerbose = verbose 38 | , optLang = lang 39 | } = opts 40 | 41 | input >>= (return . generate lang) >>= out output 42 | 43 | -------------------------------------------------------------------------------- /test/LocSpec.hs: -------------------------------------------------------------------------------- 1 | module LocSpec (main, spec) where 2 | 3 | import Loc 4 | import Data.Char (isDigit) 5 | import Control.Applicative 6 | import Control.Monad 7 | import Control.Monad.IO.Class (liftIO) 8 | import Test.Hspec 9 | import Test.QuickCheck 10 | import Debug.Trace (trace) 11 | 12 | main :: IO () 13 | main = hspec spec 14 | 15 | spec :: Spec 16 | spec = do 17 | describe "Location in code" $ do 18 | it "shows the only different line" $ 19 | loc "some bad code" "bad code" 20 | `shouldBe` 21 | Just (Loc 0 "some bad code") 22 | it "shows the second line when the first is missing" $ 23 | loc "line1\nline2" "line2" 24 | `shouldBe` 25 | Just (Loc 1 "line2") 26 | it "shows the second line when the first is missing and the second different" $ 27 | loc "line1\nline2" "2" 28 | `shouldBe` 29 | Just (Loc 1 "line2") 30 | it "shows Nothing if the same" $ 31 | loc "abc" "abc" `shouldBe` Nothing 32 | it "shows Nothing if remainder is longer than source" $ 33 | loc "abc" "efg\nbcd" `shouldBe` Nothing 34 | it "shows Nothing if the source is empty" $ 35 | loc "" "hello" `shouldBe` Nothing 36 | -------------------------------------------------------------------------------- /travis/getstack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ev 3 | 4 | unset CC 5 | if [ "x$GHCVER" = "xhead" ]; then CABALARGS=--allow-newer; fi 6 | export PATH=/opt/ghc/$GHCVER/bin:/opt/cabal/$CABALVER/bin:$HOME/.local/bin:/opt/alex/$ALEXVER/bin:/opt/happy/$HAPPYVER/bin:$HOME/.cabal/bin:$PATH 7 | mkdir -p ~/.local/bin 8 | 9 | if [ "$TRAVIS_OS_NAME" = "osx" ]; then 10 | travis_retry curl --insecure -L https://get.haskellstack.org/stable/osx-x86_64.tar.gz | tar xz --strip-components=1 --include '*/stack' -C ~/.local/bin 11 | elif [ "$TRAVIS_OS_NAME" = "windows" ]; then 12 | mkdir -p stackwin 13 | cd stackwin 14 | travis_retry curl -L https://get.haskellstack.org/stable/windows-x86_64.zip > stackwin.zip 15 | unzip stackwin.zip 16 | mkdir -p "$APPDATA\\local\\bin" 17 | export PATH="$APPDATA\\local\\bin":$PATH 18 | cp stack.exe "$APPDATA\\local\\bin" 19 | cd .. 20 | else 21 | travis_retry curl -L https://get.haskellstack.org/stable/linux-x86_64.tar.gz | tar xz --wildcards --strip-components=1 -C ~/.local/bin '*/stack' 22 | fi 23 | 24 | 25 | export PATH=/opt/ghc/$GHCVER/bin:/opt/cabal/$CABALVER/bin:$HOME/.local/bin:/opt/alex/$ALEXVER/bin:/opt/happy/$HAPPYVER/bin:$HOME/.cabal/bin:$PATH 26 | -------------------------------------------------------------------------------- /integration/wards/expressions.ts: -------------------------------------------------------------------------------- 1 | export type WardFieldValue = { isEqual: (other: WardFieldValue) => boolean }; 2 | export type WardTimestamp = {seconds: number, nanoseconds: number, toDate: ()=>Date, isEqual: (other: WardTimestamp)=>boolean, toMillis: ()=>number, valueOf: ()=>string}; 3 | export function isTimestamp(v: any): v is WardTimestamp { return !!v && (typeof v=='object') && !!v.toDate && !!v.toMillis && (typeof v.nanoseconds=='number') && (typeof v.seconds=='number')}; 4 | export type WardGeoPoint = { latitude: number, longitude: number, isEqual: (other: WardGeoPoint)=>boolean, toJSON: ()=>{latitude: number, longitude: number} } 5 | export function isGeoPoint(v: any): v is WardGeoPoint { return !!v && (typeof v=='object') && (typeof v.isEqual=='function') && (typeof v.latitude=='number') && (typeof v.longitude=='number') }; 6 | export type FirewardOutput = /** what you get from DB */ { timestamp: WardTimestamp|null; number: number; }; 7 | export type FirewardInput = /** what you send to DB */ { timestamp: WardTimestamp|Date|WardFieldValue; number: number|WardFieldValue; }; 8 | export type FirewardTypes = FirewardInput | FirewardOutput; 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/LogicPrinterSpec.hs: -------------------------------------------------------------------------------- 1 | module LogicPrinterSpec (main, spec) where 2 | 3 | import LogicPrinter 4 | import Data.List (intercalate) 5 | import Control.Applicative 6 | import Control.Monad 7 | import Test.Hspec 8 | import Test.QuickCheck 9 | import Debug.Trace (trace) 10 | 11 | main :: IO () 12 | main = hspec spec 13 | 14 | ru = intercalate "\n" 15 | _or = Term Or 16 | _and = Term And 17 | _a = Atom 18 | 19 | 20 | 21 | 22 | spec :: Spec 23 | spec = do 24 | describe "LogicPrinter" $ do 25 | it "prints an expression" $ 26 | show (Atom "x") `shouldBe` "x" 27 | it "groups operations correctly" $ do 28 | -- (a1 && (b1 || b2 || b3)) || a2 29 | let e = _or [ _and [ _a "a1" , _or [_a "b1", _or [_a "c1", _a "c2"], _and [_a "c3", _a "c4"], _a "b2", _a "b3"] ] , _a "a2" ] 30 | 31 | show e `shouldBe` ru 32 | [ "( a1" 33 | , " && ( b1" 34 | , " || ( c1" 35 | , " || c2" 36 | , " )" 37 | , " || c3" 38 | , " && c4" 39 | , " || b2" 40 | , " || b3" 41 | , " )" 42 | , " || a2" 43 | , ")" 44 | ] 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /integration/wards/paths.ts: -------------------------------------------------------------------------------- 1 | export type WardFieldValue = { isEqual: (other: WardFieldValue) => boolean }; 2 | export type WardTimestamp = {seconds: number, nanoseconds: number, toDate: ()=>Date, isEqual: (other: WardTimestamp)=>boolean, toMillis: ()=>number, valueOf: ()=>string}; 3 | export function isTimestamp(v: any): v is WardTimestamp { return !!v && (typeof v=='object') && !!v.toDate && !!v.toMillis && (typeof v.nanoseconds=='number') && (typeof v.seconds=='number')}; 4 | export type WardGeoPoint = { latitude: number, longitude: number, isEqual: (other: WardGeoPoint)=>boolean, toJSON: ()=>{latitude: number, longitude: number} } 5 | export function isGeoPoint(v: any): v is WardGeoPoint { return !!v && (typeof v=='object') && (typeof v.isEqual=='function') && (typeof v.latitude=='number') && (typeof v.longitude=='number') }; 6 | export type FirewardOutput = /** what you get from DB */ { timestamp: WardTimestamp|null; number: number; }; 7 | export type FirewardInput = /** what you send to DB */ { timestamp: WardTimestamp|Date|WardFieldValue; number: number|WardFieldValue; }; 8 | export type FirewardTypes = FirewardInput | FirewardOutput; 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Fireward Change Log 2 | 3 | ## 1.6.0 4 | 5 | _Re:_ ternary operators, `readonly` nested objects and `readonly` keyword (instead of `const`), `WardFieldValue`, quoted property names. 6 | 7 | _Breaking change:_ Fireward now requires rules version 2, and automatically adds it to the generated rules. 8 | 9 | _Breaking change:_ TS typings no longer include the Array types for old tuples. 10 | 11 | _Breaking change:_ Type `number` is no longer allowed in ward files to help avoid a common type of error. 12 | 13 | - `Changelog.md` file. 14 | - Ternary operators in expression parsers are now allowed. 15 | - read only properties now use `readonly` keyword prefixed to the name instead of `const` to the value, just like in TypeScript. 16 | - `read only` (`const`) objects are now checked using the [Firestore Map diff syntax](https://firebase.google.com/docs/reference/rules/rules.MapDiff#changedKeys). 17 | - TS now exports a `WardFieldValue` type to mimic `firestore.FieldValue` more correctly. 18 | - WardTimestamp fixed to match firestore.Timestamp in TS 19 | - property names can now be quoted to allow for almost any Unicode values. 20 | 21 | #### Internal refactoring 22 | 23 | - Simplified the type function calls by using the ternary operators. 24 | -------------------------------------------------------------------------------- /integration/wards/validations.ts: -------------------------------------------------------------------------------- 1 | export type WardFieldValue = { isEqual: (other: WardFieldValue) => boolean }; 2 | export type WardTimestamp = {seconds: number, nanoseconds: number, toDate: ()=>Date, isEqual: (other: WardTimestamp)=>boolean, toMillis: ()=>number, valueOf: ()=>string}; 3 | export function isTimestamp(v: any): v is WardTimestamp { return !!v && (typeof v=='object') && !!v.toDate && !!v.toMillis && (typeof v.nanoseconds=='number') && (typeof v.seconds=='number')}; 4 | export type WardGeoPoint = { latitude: number, longitude: number, isEqual: (other: WardGeoPoint)=>boolean, toJSON: ()=>{latitude: number, longitude: number} } 5 | export function isGeoPoint(v: any): v is WardGeoPoint { return !!v && (typeof v=='object') && (typeof v.isEqual=='function') && (typeof v.latitude=='number') && (typeof v.longitude=='number') }; 6 | export type FirewardOutput = /** what you get from DB */ { timestamp: WardTimestamp|null; number: number; }; 7 | export type FirewardInput = /** what you send to DB */ { timestamp: WardTimestamp|Date|WardFieldValue; number: number|WardFieldValue; }; 8 | export type FirewardTypes = FirewardInput | FirewardOutput; 9 | 10 | export type Pilot = { 11 | experience: Types['number'] 12 | name: string 13 | } 14 | -------------------------------------------------------------------------------- /src/LogicPrinter.hs: -------------------------------------------------------------------------------- 1 | module LogicPrinter 2 | ( Expr(..) 3 | , Op(..) 4 | , printLogic 5 | ) where 6 | 7 | import Data.Char (toUpper) 8 | import Data.List (findIndices, intercalate, stripPrefix) 9 | import Prelude hiding (showLogic) 10 | 11 | 12 | data Op = And | Or deriving (Eq) 13 | instance Show Op where 14 | show And = "&&" 15 | show Or = "||" 16 | 17 | data Expr = Term Op [Expr] | Atom String | Group Expr 18 | instance Show Expr where 19 | show = p 0 20 | 21 | 22 | shift = 2 23 | line ind = "\n" ++ indent ind 24 | indent n = take (max 0 n) $ repeat ' ' 25 | 26 | 27 | p :: Int -> Expr -> String 28 | p ind (Atom term) = term 29 | p ind (Group (Atom term)) = term 30 | p ind (Group e) = "(\n" ++ p (ind + shift) e ++ "\n" ++ indent ind ++ ")" 31 | p ind (Term op es) 32 | -- (A & (B & (C | (D | E)))) & (F | G) 33 | -- A & B & (C | D | E ) & (F | G) 34 | | parens = "( " ++ s ++ line ind ++ ")" 35 | | otherwise = s 36 | where 37 | ind' | parens = ind + 2 | otherwise = ind 38 | shift' | parens = 2 | otherwise = 0 39 | -- shift' | op == Or = shift | otherwise = 0 40 | s = intercalate (line ind' ++ show op ++ " ") $ p (ind + shift') <$> es 41 | parens = length es > 1 && op==Or 42 | 43 | printLogic = p 44 | {- 45 | assiciativity :: bool 46 | precedence :: int 47 | -} 48 | -------------------------------------------------------------------------------- /integration/wards/simple.ts: -------------------------------------------------------------------------------- 1 | export type WardFieldValue = { isEqual: (other: WardFieldValue) => boolean }; 2 | export type WardTimestamp = {seconds: number, nanoseconds: number, toDate: ()=>Date, isEqual: (other: WardTimestamp)=>boolean, toMillis: ()=>number, valueOf: ()=>string}; 3 | export function isTimestamp(v: any): v is WardTimestamp { return !!v && (typeof v=='object') && !!v.toDate && !!v.toMillis && (typeof v.nanoseconds=='number') && (typeof v.seconds=='number')}; 4 | export type WardGeoPoint = { latitude: number, longitude: number, isEqual: (other: WardGeoPoint)=>boolean, toJSON: ()=>{latitude: number, longitude: number} } 5 | export function isGeoPoint(v: any): v is WardGeoPoint { return !!v && (typeof v=='object') && (typeof v.isEqual=='function') && (typeof v.latitude=='number') && (typeof v.longitude=='number') }; 6 | export type FirewardOutput = /** what you get from DB */ { timestamp: WardTimestamp|null; number: number; }; 7 | export type FirewardInput = /** what you send to DB */ { timestamp: WardTimestamp|Date|WardFieldValue; number: number|WardFieldValue; }; 8 | export type FirewardTypes = FirewardInput | FirewardOutput; 9 | 10 | export type Name = { 11 | first: string 12 | last?: string 13 | } 14 | export type User = { 15 | name: Name | string 16 | } 17 | -------------------------------------------------------------------------------- /integration/wards/logic.ts: -------------------------------------------------------------------------------- 1 | export type WardFieldValue = { isEqual: (other: WardFieldValue) => boolean }; 2 | export type WardTimestamp = {seconds: number, nanoseconds: number, toDate: ()=>Date, isEqual: (other: WardTimestamp)=>boolean, toMillis: ()=>number, valueOf: ()=>string}; 3 | export function isTimestamp(v: any): v is WardTimestamp { return !!v && (typeof v=='object') && !!v.toDate && !!v.toMillis && (typeof v.nanoseconds=='number') && (typeof v.seconds=='number')}; 4 | export type WardGeoPoint = { latitude: number, longitude: number, isEqual: (other: WardGeoPoint)=>boolean, toJSON: ()=>{latitude: number, longitude: number} } 5 | export function isGeoPoint(v: any): v is WardGeoPoint { return !!v && (typeof v=='object') && (typeof v.isEqual=='function') && (typeof v.latitude=='number') && (typeof v.longitude=='number') }; 6 | export type FirewardOutput = /** what you get from DB */ { timestamp: WardTimestamp|null; number: number; }; 7 | export type FirewardInput = /** what you send to DB */ { timestamp: WardTimestamp|Date|WardFieldValue; number: number|WardFieldValue; }; 8 | export type FirewardTypes = FirewardInput | FirewardOutput; 9 | 10 | export type Name = { 11 | first: string 12 | last?: string 13 | } 14 | export type User = { 15 | name: Name | string 16 | } 17 | 18 | -------------------------------------------------------------------------------- /integration/wards/optionalTypes.ts: -------------------------------------------------------------------------------- 1 | export type WardFieldValue = { isEqual: (other: WardFieldValue) => boolean }; 2 | export type WardTimestamp = {seconds: number, nanoseconds: number, toDate: ()=>Date, isEqual: (other: WardTimestamp)=>boolean, toMillis: ()=>number, valueOf: ()=>string}; 3 | export function isTimestamp(v: any): v is WardTimestamp { return !!v && (typeof v=='object') && !!v.toDate && !!v.toMillis && (typeof v.nanoseconds=='number') && (typeof v.seconds=='number')}; 4 | export type WardGeoPoint = { latitude: number, longitude: number, isEqual: (other: WardGeoPoint)=>boolean, toJSON: ()=>{latitude: number, longitude: number} } 5 | export function isGeoPoint(v: any): v is WardGeoPoint { return !!v && (typeof v=='object') && (typeof v.isEqual=='function') && (typeof v.latitude=='number') && (typeof v.longitude=='number') }; 6 | export type FirewardOutput = /** what you get from DB */ { timestamp: WardTimestamp|null; number: number; }; 7 | export type FirewardInput = /** what you send to DB */ { timestamp: WardTimestamp|Date|WardFieldValue; number: number|WardFieldValue; }; 8 | export type FirewardTypes = FirewardInput | FirewardOutput; 9 | 10 | export type Name = { 11 | first: string 12 | last?: string 13 | } 14 | export type OptionalTypesExample = { 15 | str?: string 16 | num?: Types['number'] 17 | sub?: Name 18 | } 19 | -------------------------------------------------------------------------------- /integration/wards/primitiveTypes.ward: -------------------------------------------------------------------------------- 1 | type ListTest = { 2 | test: string[] 3 | } 4 | type OptListTest = { 5 | test?: string[] 6 | } 7 | type MapTest = { 8 | test: map 9 | } 10 | type LitTest = { 11 | strTest: 'me' | 'you' 12 | numTest: 123 | 234 13 | boolTest: false 14 | mixTest: 'hello' | 123 | true 15 | } 16 | type TimestampTest = { 17 | test: timestamp 18 | } 19 | type TimestampNullTest = { 20 | test: timestamp|null 21 | } 22 | type AnyTest = { 23 | test: any 24 | } 25 | type GeoTest = { 26 | test: latlng 27 | } 28 | type UTF8Test = { 29 | } 30 | type QuotedTest = { 31 | "ハロー・ワールド": string 32 | "abc": { 33 | readonly "..-": { 34 | "]": float 35 | } 36 | } 37 | } 38 | 39 | match /map/{x} is MapTest { 40 | allow write: true; 41 | } 42 | match /olist/{x} is OptListTest { 43 | allow write: true; 44 | } 45 | match /list/{x} is ListTest { 46 | allow write: true; 47 | } 48 | match /literal/{x} is LitTest { 49 | allow write: true; 50 | } 51 | match /time/{x} is TimestampTest { 52 | allow write: true; 53 | allow read: true; 54 | } 55 | match /time-null/{x} is TimestampNullTest { 56 | allow write: true; 57 | allow read: true; 58 | } 59 | 60 | match /geo/{x} is GeoTest { 61 | allow write: true; 62 | allow read: true; 63 | } 64 | match /any/{x} is AnyTest { 65 | allow write: true; 66 | allow read: true 67 | } 68 | match /quoted/{x} is QuotedTest { 69 | allow write: true; 70 | allow read: true; 71 | } -------------------------------------------------------------------------------- /examples/complete.ward: -------------------------------------------------------------------------------- 1 | rules_version = '2' // optional, see https://firebase.google.com/docs/firestore/security/get-started#security_rules_version_2 2 | 3 | type User = { 4 | name: { first: string, last: string }, // inline nested objects 5 | friends: string[], // a list of strings (string type not validated) 6 | tags: [string, string, string | int, string?], // a 4-tuple, max size 4, last item optional (type IS validated) 7 | age?: int, // optional type 8 | verified: bool 9 | contact: Phone | Email // a union type 10 | uid: const string // const prevents editing this field later 11 | permissions: map // corresponds to `map` type in the rules and `Record` in TS 12 | smorgasBoard: "hi" | "bye" | true | 123 // literal types, same as in TS 13 | 14 | irrelevant: any 15 | 16 | // Custom type validation expressions go at the end of any type 17 | allow update: if data.age > prev.age // data refers to this type's incoming data, prev refers to previously stored data. 18 | allow write: if request.time > 123 // shorthand for create, update, delete 19 | allow create, update: if data.verified == true // allows to group multiple methods into a single expression 20 | } 21 | 22 | type Phone = { number: int, country: int } 23 | type Email = string 24 | 25 | function isLoggedInUser(userId) { 26 | // return keyword optional 27 | return request.auth!=null && request.auth.uid == userId; 28 | } 29 | 30 | match /users/{userId} is User { 31 | // read, write, create, update, list, get and delete conditions are allowed 32 | allow read, create, update: if isLoggedInUser(userId); 33 | allow delete: false; 34 | } 35 | -------------------------------------------------------------------------------- /src/ExprPrinter.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -fwarn-incomplete-patterns #-} 2 | module ExprPrinter ( 3 | printExpr 4 | ) where 5 | 6 | import ExprParser 7 | import Data.List (intercalate) 8 | 9 | commajoin = intercalate ", " 10 | 11 | printExpr :: Expr -> String 12 | printExpr (ExprGrp expr) = "(" ++ printExpr expr ++ ")" 13 | printExpr (ExprBin op left right) = if op==OpDot 14 | then printExpr left ++ show op ++ printExpr right 15 | else printExpr left ++ " " ++ show op ++ " " ++ printExpr right 16 | printExpr (ExprCall call) = printFuncCall call -- funcName ++ "(" ++ printArr ", " exprs ++ ")" 17 | printExpr (ExprVar var) = var 18 | printExpr (ExprUn op expr) = show op ++ printExpr expr 19 | printExpr (ExprStr s) = s-- "\"" ++ s ++ "\"" 20 | printExpr (ExprFloat num) = show num 21 | printExpr (ExprInt num) = show num 22 | printExpr (ExprBool True) = "true" 23 | printExpr (ExprBool False) = "false" 24 | printExpr (ExprNull) = "null" 25 | printExpr (ExprIndexed e i r) = printExpr e ++ "[" ++ printExpr i ++ printIxRange r ++ "]" 26 | printExpr (ExprPath parts) = "/" ++ intercalate "/" (fmap printPathPart parts) ++ "" 27 | printExpr (ExprList es) = "[" ++ (commajoin [ printExpr e | e <- es ]) ++ "]" 28 | printExpr (ExprMap kvs) = "{ " ++ (commajoin [ printKeyVal kv | kv <- kvs]) ++ " }" 29 | printExpr (ExprTern c t f) = printExpr c ++ " ? " ++ printExpr t ++ " : " ++ printExpr f 30 | 31 | printPathPart (PathPartLit s) = s 32 | printPathPart (PathPartExpr e) = "$("++printExpr e++")" 33 | 34 | printFuncCall (FuncCall funcName exprs) = funcName ++ "(" ++ printArr ", " exprs ++ ")" 35 | 36 | printKeyVal (s, e) = s ++ ": " ++ printExpr e 37 | printIxRange Nothing = "" 38 | printIxRange (Just r) = ":" ++ printExpr r 39 | printArr sep es = intercalate sep [ printExpr e | e <- es ] 40 | -------------------------------------------------------------------------------- /integration/wards/const.ts: -------------------------------------------------------------------------------- 1 | export type WardFieldValue = { isEqual: (other: WardFieldValue) => boolean }; 2 | export type WardTimestamp = {seconds: number, nanoseconds: number, toDate: ()=>Date, isEqual: (other: WardTimestamp)=>boolean, toMillis: ()=>number, valueOf: ()=>string}; 3 | export function isTimestamp(v: any): v is WardTimestamp { return !!v && (typeof v=='object') && !!v.toDate && !!v.toMillis && (typeof v.nanoseconds=='number') && (typeof v.seconds=='number')}; 4 | export type WardGeoPoint = { latitude: number, longitude: number, isEqual: (other: WardGeoPoint)=>boolean, toJSON: ()=>{latitude: number, longitude: number} } 5 | export function isGeoPoint(v: any): v is WardGeoPoint { return !!v && (typeof v=='object') && (typeof v.isEqual=='function') && (typeof v.latitude=='number') && (typeof v.longitude=='number') }; 6 | export type FirewardOutput = /** what you get from DB */ { timestamp: WardTimestamp|null; number: number; }; 7 | export type FirewardInput = /** what you send to DB */ { timestamp: WardTimestamp|Date|WardFieldValue; number: number|WardFieldValue; }; 8 | export type FirewardTypes = FirewardInput | FirewardOutput; 9 | 10 | export type StrTest = { 11 | test: string 12 | name: string 13 | } 14 | export type IntTest = { 15 | test: Types['number'] 16 | name: string 17 | } 18 | export type FloatTest = { 19 | test: Types['number'] 20 | name: string 21 | } 22 | export type BoolTest = { 23 | test: boolean 24 | name: string 25 | } 26 | export type MapTest = { 27 | test: Record 28 | name: string 29 | } 30 | export type OptTest = { 31 | test?: string 32 | name: string 33 | } 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /integration/wards/arraysAndTuples.ts: -------------------------------------------------------------------------------- 1 | export type WardFieldValue = { isEqual: (other: WardFieldValue) => boolean }; 2 | export type WardTimestamp = {seconds: number, nanoseconds: number, toDate: ()=>Date, isEqual: (other: WardTimestamp)=>boolean, toMillis: ()=>number, valueOf: ()=>string}; 3 | export function isTimestamp(v: any): v is WardTimestamp { return !!v && (typeof v=='object') && !!v.toDate && !!v.toMillis && (typeof v.nanoseconds=='number') && (typeof v.seconds=='number')}; 4 | export type WardGeoPoint = { latitude: number, longitude: number, isEqual: (other: WardGeoPoint)=>boolean, toJSON: ()=>{latitude: number, longitude: number} } 5 | export function isGeoPoint(v: any): v is WardGeoPoint { return !!v && (typeof v=='object') && (typeof v.isEqual=='function') && (typeof v.latitude=='number') && (typeof v.longitude=='number') }; 6 | export type FirewardOutput = /** what you get from DB */ { timestamp: WardTimestamp|null; number: number; }; 7 | export type FirewardInput = /** what you send to DB */ { timestamp: WardTimestamp|Date|WardFieldValue; number: number|WardFieldValue; }; 8 | export type FirewardTypes = FirewardInput | FirewardOutput; 9 | 10 | export type Tuple1 = [string, 3, 'hello', { 11 | a: 1 12 | }?] 13 | export type A = { 14 | test: Tuple1 15 | } 16 | 17 | export type B = { 18 | test: [Types['number'], Types['number'], Types['number']?, Types['number']?] 19 | } 20 | 21 | export type Test1 = { 22 | a: 1 23 | } 24 | export type C = { 25 | test: Test1[] 26 | } 27 | 28 | export type Session = { 29 | dayOfWeek: Types['number'] 30 | } 31 | export type Booking = { 32 | sessions: [Session, Session?] 33 | } 34 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | EXEC:=fireward 4 | 5 | test: 6 | stack test --file-watch 7 | 8 | exec-path: 9 | @echo "$(shell stack path --dist-dir)/build/fireward/fireward" 10 | 11 | tmp/try.rules: ./.stack-work/dist/x86_64-osx/Cabal-1.24.2.0/build/fireward/fireward 12 | tmp/try.rules: $(file) 13 | test -r "$(file)" 14 | stack exec fireward -- -i $(file) > tmp/try.rules 15 | cat tmp/try.rules 16 | 17 | try: $(file) 18 | stack build 19 | test -r "$(file)" 20 | stack exec fireward -- -i $(file) 21 | 22 | 23 | prefix?=/usr/local/bin 24 | PREFIX:=$(prefix) 25 | 26 | 27 | LOCAL_PATH:=$(shell stack path --local-bin) 28 | install: 29 | stack install && cp $(LOCAL_PATH)/$(EXEC) $(prefix)/ 30 | 31 | VERSION=$(shell stack exec fireward -- -V) 32 | 33 | buildtest: 34 | stack build 35 | # stack test 36 | 37 | V=$(shell stack exec fireward -- -V) 38 | tag: 39 | git tag -a "$(V)" 40 | 41 | release: 42 | make buildtest 43 | git push origin master # prevent travis from building anything but the tag 44 | make tag 45 | git push origin master --follow-tags 46 | 47 | v?=$(V) 48 | publish: 49 | cd npm-bin && ./publish.sh 50 | 51 | z: 52 | echo $(shell date +%s) 53 | 54 | BRANCH:=$(shell git branch --show-current) 55 | release-beta: 56 | [ "$(BRANCH)" != "master" ] 57 | make buildtest 58 | git push origin $(BRANCH) # prevent travis from building anything but the tag 59 | git tag -a "$(V)-beta.$(shell date +%s)" 60 | git push origin $(BRANCH) --follow-tags 61 | 62 | 63 | publish-beta: 64 | cd npm-bin && ./publish.sh beta 65 | 66 | workExe:=$(shell stack path --dist-dir)/build/fireward/fireward 67 | 68 | watch-complex: 69 | watch -i 1 make .test-complex 70 | .test-complex: $(workExe) 71 | @stack build 72 | touch .test-complex 73 | $(workExe) -i ./test/fixtures/indent.ward 74 | 75 | smoke-test: 76 | cat examples/smoke-test.ward | stack exec fireward > /dev/null 77 | 78 | bench: 79 | hyperfine 'fireward -i examples/smoke-test.ward' 80 | 81 | e2e: 82 | cd ./integration && npm test 83 | -------------------------------------------------------------------------------- /integration/test/paths.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The logic of allow rules in combination with type checks 3 | */ 4 | 5 | import firebaseTesting = require('@firebase/rules-unit-testing'); 6 | import {RulesTestEnvironment, RulesTestContext} from '@firebase/rules-unit-testing' 7 | import {getRules} from '../util/rules'; 8 | 9 | const WARD_NAME = 'paths'; 10 | 11 | let app: RulesTestContext; 12 | let testEnv: RulesTestEnvironment 13 | let firestore: ReturnType 14 | 15 | const projectId = WARD_NAME; 16 | const uid = '123'; 17 | 18 | 19 | before(async function() { 20 | 21 | const rules = getRules(WARD_NAME); 22 | testEnv = await firebaseTesting.initializeTestEnvironment({projectId, firestore: {rules}}); 23 | 24 | app = testEnv.authenticatedContext(uid, {}); 25 | firestore = app.firestore(); 26 | }) 27 | 28 | describe(WARD_NAME, async function() { 29 | let count = 0; 30 | 31 | beforeEach(async function() { 32 | count++; 33 | }); 34 | 35 | afterEach(async () => { 36 | testEnv.clearFirestore(); 37 | }); 38 | 39 | describe(`authenticated`, function() { 40 | beforeEach(function () { 41 | }); 42 | 43 | it(`allows a complex exists condition`, async function() { 44 | await firestore.collection('refz').doc('qq').set({}) 45 | await firebaseTesting.assertSucceeds(firestore.collection('exists').doc('refz').set({})) 46 | }); 47 | 48 | it(`allows read when condition uses function with custom let bindings`, async function() { 49 | const ref = firestore.collection('testTrueFunc').doc('x'); 50 | await ref.set({}); 51 | await firebaseTesting.assertSucceeds(ref.get()) 52 | }); 53 | 54 | it(`denies read when condition uses function with custom let bindings`, async function() { 55 | const ref = firestore.collection('testFalseFunc').doc('x'); 56 | await ref.set({}); 57 | await firebaseTesting.assertFails(ref.get()) 58 | }); 59 | 60 | // it(`denies write of a directive with 'false' body`, async function() { 61 | // await firebase.assertFails(firestore.collection('dir').doc(uid).set({})) 62 | // }) 63 | }) 64 | }) 65 | 66 | -------------------------------------------------------------------------------- /src/RuleLang.hs: -------------------------------------------------------------------------------- 1 | module RuleLang ( 2 | _function, 3 | _hasAny, 4 | _hasAll, 5 | _hasOnly, 6 | _allow, 7 | _sizeLte, 8 | _sizeGte, 9 | _sizeBetween, 10 | _pathBlock, 11 | _enquote, 12 | _and, _or, 13 | _not 14 | ) where 15 | 16 | import Parser 17 | import RuleParser 18 | import Error (Error(..)) 19 | import Loc (loc, Loc) 20 | import Data.List (findIndices, intercalate, stripPrefix) 21 | import Data.Char (toUpper) 22 | import Data.Maybe (maybe) 23 | import CodePrinter 24 | 25 | spaces n = take n $ repeat ' ' 26 | moveBy n = (spaces (n*2) ++) 27 | 28 | 29 | _enquote :: [Char] -> [Char] -- enquote unless already quoted 30 | _enquote name = let q = take 1 name 31 | in if q == "'" || q == "\"" then name else "'" ++ name ++ "'" 32 | 33 | _enquoteList x = _list $ _enquote <$> x 34 | _list = intercalate ", " 35 | 36 | _hasAny parent elements = parent ++ ".keys().hasAny([" ++ _enquoteList elements ++ "])" 37 | _hasAll parent elements = parent ++ ".keys().hasAll([" ++ _enquoteList elements ++ "])" 38 | _hasOnly parent elements = parent ++ ".keys().hasOnly([" ++ _enquoteList elements ++ "])" 39 | 40 | _allow conditions expr = do 41 | _print $ "allow " ++ _list conditions ++ ": " 42 | expr 43 | 44 | _sizeLte item max = item ++ ".size() <= " ++ show max 45 | _sizeGte item min = item ++ ".size() >= " ++ show min 46 | _sizeBetween item min max = _sizeGte item min ++ " && " ++ _sizeLte item max 47 | 48 | _and = _print "&& " 49 | _or = _print "|| " 50 | 51 | _not el = "!(" ++ el ++ ")" 52 | 53 | _function name params vars body = do 54 | _print "function " 55 | _print name 56 | _print "(" 57 | _print $ _list params 58 | _print ") {" 59 | _indent 60 | _return 61 | _lines [ _print $ "let " ++ vname ++ " = " ++ vval ++ ";" | (vname, vval) <- vars ] 62 | _printIf (length vars > 0) _return 63 | _print "return " 64 | body 65 | _print ";" 66 | _deindent 67 | _return 68 | _print "}" 69 | 70 | _pathBlock parts typeFunc bodyItems= do 71 | _print "match /" 72 | _printMany parts 73 | _print " {" 74 | _indent 75 | _return 76 | typeFunc 77 | _return 78 | bodyItems 79 | _deindent 80 | _return 81 | _print "}" 82 | _return 83 | 84 | -------------------------------------------------------------------------------- /fireward.cabal: -------------------------------------------------------------------------------- 1 | name: fireward 2 | version: 2.0.19 3 | -- synopsis: 4 | -- description: 5 | homepage: https://github.com/trouvaille/fireward#readme 6 | license: MIT 7 | license-file: LICENSE 8 | author: Bijou Trouvaille 9 | maintainer: github.com/bijoutrouvaille 10 | copyright: 2017-2020 Bijou Trouvaille 11 | category: Web 12 | build-type: Simple 13 | extra-source-files: readme.md 14 | cabal-version: >=1.10 15 | 16 | library 17 | hs-source-dirs: src 18 | exposed-modules: Parser 19 | , RuleParser 20 | , RuleGenerator 21 | , RuleLang 22 | , CodePrinter 23 | , Combinators 24 | , ExprParser 25 | , ExprPrinter 26 | , OptionParser 27 | , TSGenerator 28 | , Loc 29 | , Error 30 | , LogicPrinter 31 | 32 | other-modules: Paths_fireward 33 | build-depends: base >= 4.7 && < 5 34 | default-language: Haskell2010 35 | 36 | executable fireward 37 | hs-source-dirs: app 38 | main-is: Main.hs 39 | ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wincomplete-patterns 40 | build-depends: base 41 | , fireward 42 | default-language: Haskell2010 43 | 44 | test-suite fireward-test 45 | type: exitcode-stdio-1.0 46 | hs-source-dirs: test 47 | main-is: Spec.hs 48 | build-depends: base 49 | , fireward 50 | , hspec 51 | , QuickCheck 52 | other-modules: ExprParserSpec 53 | , LocSpec 54 | , LogicPrinterSpec 55 | , RuleLangSpec 56 | , OptionParserSpec 57 | , ParserSpec 58 | , RuleGeneratorSpec 59 | , RuleParserSpec 60 | , TSGeneratorSpec 61 | ghc-options: -threaded -rtsopts -with-rtsopts=-N 62 | default-language: Haskell2010 63 | 64 | source-repository head 65 | type: git 66 | location: https://github.com/bijoutrouvaille/fireward 67 | -------------------------------------------------------------------------------- /integration/util/rules.ts: -------------------------------------------------------------------------------- 1 | import {readFileSync, writeFileSync} from 'fs'; 2 | import {execSync} from 'child_process'; 3 | 4 | import firebaseTesting = require('@firebase/rules-unit-testing'); 5 | import {RulesTestEnvironment, RulesTestContext} from '@firebase/rules-unit-testing' 6 | 7 | const isWin = process.platform.toLocaleLowerCase().includes('windows'); 8 | const execPath = '../'+execSync(`stack path --dist-dir`, {encoding: 'utf8'}).trim() + `/build/fireward/fireward` 9 | + (isWin ? '.exe' : ''); 10 | console.log('exec path', execPath) 11 | const tryRead = (path: string) => { 12 | try { 13 | return (readFileSync(path, {encoding: 'utf8'}) || '').trim(); 14 | } catch (error) { 15 | return null; 16 | } 17 | } 18 | export const getRules = function getRules(wardFile: string) { 19 | const name = wardFile.replace(/\.ward$/,''); 20 | const wardName = `./wards/${name}.ward`; 21 | const tsName = `./wards/${name}.ts`; 22 | 23 | const rules = execSync(execPath + ` -i ${wardName}`, {encoding: 'utf8'}); 24 | const ts = execSync(execPath + ` -i ${wardName} -l typescript`, {encoding: 'utf8'}); 25 | const prevTs = tryRead(tsName); 26 | if (ts && ts.trim()!==prevTs?.trim()) { 27 | writeFileSync(tsName, ts, {encoding: 'utf8'}); 28 | } 29 | return rules 30 | } 31 | // /** 32 | // * Does 3 things: 33 | // * - Compiles the ward into rules 34 | // * - Loads the rules into the firebase app. 35 | // * - Generates a .ts file inside the wards directory. 36 | // * @param wardFile ward file name that resides in the wards directory. Extension can be omitted. 37 | // * @param [app] optional firebase app. By default the first one will be used. 38 | // */ 39 | // export const loadRules = function loadRules(wardFile: string, app: RulesTestContext) { 40 | 41 | // const name = wardFile.replace(/\.ward$/,''); 42 | // const wardName = `./wards/${name}.ward`; 43 | // const tsName = `./wards/${name}.ts`; 44 | 45 | // const rules = execSync(execPath + ` -i ${wardName}`, {encoding: 'utf8'}); 46 | // const ts = execSync(execPath + ` -i ${wardName} -l typescript`, {encoding: 'utf8'}); 47 | // const prevTs = tryRead(tsName); 48 | // if (ts && ts.trim()!==prevTs?.trim()) { 49 | // writeFileSync(tsName, ts, {encoding: 'utf8'}); 50 | // } 51 | 52 | // const projectId = app.options.projectId 53 | // return firebase.loadFirestoreRules({rules, projectId}) 54 | // } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: c 2 | cache: 3 | directories: 4 | - "$HOME/.ghc" 5 | - "$HOME/.cabal" 6 | - "$HOME/.stack" 7 | - "$TRAVIS_BUILD_DIR/.stack-work" 8 | - "$TRAVIS_BUILD_DIR\\.stack-work" 9 | - "$APPDATA\\Roaming\\stack" 10 | - "$APPDATA\\Local\\Programs\\stack" 11 | - "$TRAVIS_BUILD_DIR\\.stack-work" 12 | 13 | matrix: 14 | include: 15 | - os: windows 16 | - os: osx 17 | - addons: 18 | apt: 19 | packages: 20 | - libgmp-dev 21 | branches: 22 | only: 23 | - /\d+\.\d+\.\d+/ 24 | - /\d+\.\d+\.\d+-beta.\d+/ 25 | 26 | install: source ./travis/getstack.sh 27 | script: source ./travis/buildfile.sh 28 | 29 | deploy: 30 | provider: releases 31 | skip_cleanup: true 32 | draft: false 33 | api_key: 34 | secure: ltA1aV0OlIHc6Hv5YaCATYGz6+vT43L/RU2tS8oWCy38fTjesPUz7KozjOg4FOghapGOq9hF3h3kAELIK3XfF/gSzN/O93NRwqT8SRJgzUwDumj8UQDrZFwiUK5f62wuPKuahkES+70EJrg0ZwcYnq+CkGAMo+N3y5uJYxlhU7v57gpnPIlbTK/afoFBbF6HTJkVxd8QZD04bn5hU6SkU1Y3bLl57RTGrzcsvr1In8p7ViOh4I0EPMZy3xUrmP+wNiv0XOt2CqgXEl526q5w0mdRVpcefxt2z0bxFoLYAXBPj6EwmlRI+Ogv14FNXVtTrqP43A8SoR7sXo1+dEQNxLvmIoc6sLhqXDuDdH9/CY/NuifV1gOtCBJTPZ11DAaJsDOPZY1KYRSN5ZiXfk1xn/hBIjXlIo5lm9EoPmG0BZmcP49V34x+4as4/9KMo7tQFTnf6+ZOnQs/aa98SG0RxeojMF908vtfwj1AXJqTXGRtv020oL2UOVNHiyGHIgWGGnEm+Bq6aIvnX3DDoCk9iDOug0y9zl2h/bo0nxo67tQvLDwHEN2JcsKsUIFc3clvPBQSHX6HThkxvKzrWM1FEAVgGMD4tcby8SbL7S1rxEPuMUA6SpLNNiFyy3SKBd0zZV2BGEE/oIjSxpwn4pAQ/jG8JjM9XCpzBn/ZyO6PSRE= 35 | file: ${DEPLOY_FILE} 36 | on: 37 | repo: bijoutrouvaille/fireward 38 | tags: true 39 | all_branches: true 40 | 41 | # jobs: 42 | # include: 43 | # - stage: npmjs 44 | # name: npmdeploy 45 | # language: node_js 46 | # script: 47 | # - | 48 | # cd npm-bin 49 | # curl -L https://github.com/bijoutrouvaille/fireward/releases/${TRAVIS_TAG-fail}/fireward-linux.tar | tar x > fireward-linux 50 | # curl -L https://github.com/bijoutrouvaille/fireward/releases/${TRAVIS_TAG-fail}/fireward-osx.tar | tar x > fireward-osx 51 | # curl -L https://github.com/bijoutrouvaille/fireward/releases/${TRAVIS_TAG-fail}/fireward-windows.tar | tar x > fireward.exe 52 | # npm version "$TRAVIS_TAG" 53 | # echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc 54 | # npm publish 55 | # 56 | # 57 | # 58 | # 59 | # stages: 60 | # - githubr 61 | # - npmjs 62 | 63 | git: 64 | quiet: true 65 | depth: 1 66 | -------------------------------------------------------------------------------- /integration/test/simple.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import {expect} from 'chai'; 3 | import firebaseTesting = require('@firebase/rules-unit-testing'); 4 | import {RulesTestEnvironment, RulesTestContext} from '@firebase/rules-unit-testing' 5 | import {getRules} from '../util/rules'; 6 | 7 | import {User, Name} from '../wards/simple'; 8 | 9 | const WARD_NAME = 'simple'; 10 | 11 | let app: RulesTestContext; 12 | let testEnv: RulesTestEnvironment; 13 | let firestore: ReturnType; 14 | 15 | const projectId = WARD_NAME.toLowerCase(); 16 | const uid = '123'; 17 | 18 | before(async function() { 19 | const rules = getRules(WARD_NAME); 20 | testEnv = await firebaseTesting.initializeTestEnvironment({projectId, firestore: {rules}}) 21 | }) 22 | 23 | describe(WARD_NAME, function() { 24 | let count = 0; 25 | beforeEach(async function() { 26 | count++; 27 | }) 28 | afterEach(async () => { 29 | testEnv.clearFirestore(); 30 | }) 31 | describe(`authenticated`, function() { 32 | beforeEach(function() { 33 | app = testEnv.authenticatedContext(uid, {}); 34 | firestore = app.firestore() 35 | }); 36 | 37 | it(`succeeds update`, async function() { 38 | 39 | const user: User = { name: {first: 'Fire', last: "Ward"} } 40 | await firestore.collection(`users`).doc(uid).set(user); 41 | const u2: User = { name: 'FireWard' } 42 | await firebaseTesting.assertSucceeds(firestore.collection(`users`).doc(uid).set(u2)); 43 | }); 44 | 45 | }); 46 | 47 | describe(`un-authenticated`, function() { 48 | 49 | beforeEach(function (){ 50 | app = testEnv.unauthenticatedContext(); 51 | firestore = app.firestore() 52 | }); 53 | 54 | it(`succeeds read`, function() { 55 | return firebaseTesting.assertSucceeds(firestore.collection('users').doc('123').get()) 56 | }); 57 | 58 | it(`succeeds create`, async function() { 59 | const user: User = { name: {first: 'Fire', last: "Ward"} } 60 | return firestore.collection(`users`).add(user) 61 | }); 62 | 63 | it(`fails update`, async function() { 64 | const user: User = { name: {first: 'Fire', last: "Ward"} } 65 | const ref = await firestore.collection(`users`).add(user); 66 | const u2: User = { name: 'FireWard' } 67 | await firebaseTesting.assertFails(firestore.collection(`users`).doc(ref.id).set(u2)); 68 | }); 69 | 70 | }); 71 | 72 | }); 73 | -------------------------------------------------------------------------------- /integration/wards/primitiveTypes.ts: -------------------------------------------------------------------------------- 1 | export type WardFieldValue = { isEqual: (other: WardFieldValue) => boolean }; 2 | export type WardTimestamp = {seconds: number, nanoseconds: number, toDate: ()=>Date, isEqual: (other: WardTimestamp)=>boolean, toMillis: ()=>number, valueOf: ()=>string}; 3 | export function isTimestamp(v: any): v is WardTimestamp { return !!v && (typeof v=='object') && !!v.toDate && !!v.toMillis && (typeof v.nanoseconds=='number') && (typeof v.seconds=='number')}; 4 | export type WardGeoPoint = { latitude: number, longitude: number, isEqual: (other: WardGeoPoint)=>boolean, toJSON: ()=>{latitude: number, longitude: number} } 5 | export function isGeoPoint(v: any): v is WardGeoPoint { return !!v && (typeof v=='object') && (typeof v.isEqual=='function') && (typeof v.latitude=='number') && (typeof v.longitude=='number') }; 6 | export type FirewardOutput = /** what you get from DB */ { timestamp: WardTimestamp|null; number: number; }; 7 | export type FirewardInput = /** what you send to DB */ { timestamp: WardTimestamp|Date|WardFieldValue; number: number|WardFieldValue; }; 8 | export type FirewardTypes = FirewardInput | FirewardOutput; 9 | 10 | export type ListTest = { 11 | test: string[] 12 | } 13 | export type OptListTest = { 14 | test?: string[] 15 | } 16 | export type MapTest = { 17 | test: Record 18 | } 19 | export type LitTest = { 20 | strTest: 'me' | 'you' 21 | numTest: 123 | 234 22 | boolTest: false 23 | mixTest: 'hello' | 123 | true 24 | } 25 | export type TimestampTest = { 26 | test: Types['timestamp'] 27 | } 28 | export type TimestampNullTest = { 29 | test: Types['timestamp'] | null 30 | } 31 | export type AnyTest = { 32 | test: any 33 | } 34 | export type GeoTest = { 35 | test: WardGeoPoint 36 | } 37 | export type UTF8Test = { 38 | 39 | } 40 | export type QuotedTest = { 41 | "ハロー・ワールド": string 42 | "abc": { 43 | "..-": { 44 | "]": Types['number'] 45 | } 46 | } 47 | } 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/CodePrinter.hs: -------------------------------------------------------------------------------- 1 | module CodePrinter ( 2 | printCode, 3 | _print, 4 | _return, 5 | _blank, 6 | _indent, 7 | _deindent, 8 | _indented, 9 | _getIndent, 10 | _linesWith, 11 | _line, 12 | _lines, 13 | _printIf, 14 | _printMany, 15 | CodePrinter 16 | ) where 17 | 18 | import Data.Char (isDigit , isLower , isUpper, isSpace) 19 | import Data.List (dropWhileEnd) 20 | 21 | import Control.Monad ((>>=), return, ap, liftM, guard) 22 | import Control.Applicative (Alternative, (<|>), empty, many, some, optional) 23 | import Prelude hiding (head) 24 | 25 | 26 | 27 | newtype State s a = State (s -> (a, s)) 28 | 29 | instance Functor (State s) where 30 | fmap = liftM 31 | 32 | instance Applicative (State s) where 33 | pure = return 34 | (<*>) = ap 35 | 36 | apply (State p) s = p s 37 | state :: (s -> (a, s)) -> State s a 38 | state f = State (\s -> f s) 39 | 40 | instance Monad (State s) where 41 | return x = state (\s -> (x, s)) 42 | p >>= k = state $ \ s0 -> 43 | let (x, s1) = apply p s0 44 | in apply (k x) s1 45 | 46 | put newState = state $ \_ -> ((), newState) 47 | get = state $ \s -> (s, s) 48 | 49 | type CodePrinter = State (String, Int) () 50 | 51 | cr = "\n" 52 | space :: Int -> String 53 | space n = take n $ repeat ' ' 54 | _getIndent = do 55 | (code, i)<-get 56 | return i 57 | 58 | _print s = do 59 | (c,i) <- get 60 | put (c ++ s, i) 61 | 62 | _return = do 63 | i <- _getIndent 64 | _print cr 65 | _print $ space (i * 2) 66 | 67 | _line s = do 68 | _return 69 | s 70 | 71 | _shift :: Int -> CodePrinter 72 | _shift n = do 73 | (code, i) <- get 74 | put (code, if i + n >= 0 then i + n else 0) 75 | 76 | _indent = do 77 | _shift 1 78 | _deindent = do 79 | _shift (-1) 80 | 81 | _indented p = do 82 | _indent 83 | p 84 | _deindent 85 | 86 | _blank :: CodePrinter 87 | _blank = state (\s->((), s)) 88 | _manyWith s [] = _print "" 89 | _manyWith s (p:[]) = p 90 | _manyWith s (p:ps) = p >> s >> _manyWith s ps 91 | 92 | _printMany [] = _blank 93 | _printMany (p:printers) = p >> _printMany printers 94 | 95 | 96 | _lines ps = _manyWith _return ps 97 | _linesWith s printers = _manyWith (_return >> s) printers 98 | 99 | _printIf condition true = do 100 | if condition then true else _print "" 101 | 102 | 103 | printCode initialIndent printer = fst . snd $ apply printer ("", initialIndent) 104 | -------------------------------------------------------------------------------- /integration/util/emulator.ts: -------------------------------------------------------------------------------- 1 | import {spawn, execSync} from 'child_process'; 2 | 3 | import json = require('../firebase.json'); 4 | 5 | const EXE = './node_modules/.bin/firebase'; 6 | const port = (process.env['FIRESTORE_EMULATOR_HOST']||'').trim().split(':')[1] || json.emulators.firestore.port; 7 | 8 | let ready = false; 9 | 10 | function start() { 11 | 12 | const exc = (cmd: string)=>{ 13 | const res = execSync(cmd, { 14 | shell: 'bash', 15 | uid: process.getuid() 16 | }); 17 | return res && res.toString().trim() || ''; 18 | } 19 | 20 | 21 | try { 22 | const prevPid = exc(`lsof -i :${port} | grep -v COMMAND | awk '{print $2}'`); 23 | if (prevPid) { 24 | ready = true; 25 | return; 26 | }; 27 | } catch (error: any) { 28 | console.log('failed with previous pid', { 29 | // err: error.stderr.toString(), 30 | // out: error.stdout.toString(), 31 | msg: error.message, 32 | error 33 | }); 34 | process.exit(1); 35 | } 36 | 37 | const proc = spawn(EXE, `emulators:start --only firestore`.split(' '), { 38 | stdio: 'pipe' 39 | }); 40 | 41 | proc.stdout.on('data', (data) => { 42 | console.log(`EMULATOR stdout: ${data}`); 43 | const msg = `${data}`; 44 | if (msg.indexOf('It is now safe to connect your app.') > -1) { 45 | ready = true; 46 | } 47 | }); 48 | 49 | 50 | proc.stderr.on('data', (data) => { 51 | console.log(`EMULATOR stderr: ${data}`); 52 | }); 53 | 54 | proc.on('close', (code, signal)=>{ 55 | console.log(`Emulator closed with code ${code}: ${signal}.\nExitting.`) 56 | process.exit(code || 0); 57 | }) 58 | proc.on('error', e=>{ 59 | console.error('Emulator error', e); 60 | process.exit(1); 61 | }) 62 | proc.on('message', msg => { 63 | console.log('EMULATOR:', msg); 64 | if (msg.toString().indexOf('It is now safe to connect your app.') > -1) { 65 | ready = true; 66 | } 67 | }) 68 | proc.on('exit', (code, signal)=>{ 69 | console.log(`Emulator exited with code ${code}: ${signal}.\nExitting.`) 70 | process.exit(code || 0); 71 | }) 72 | proc.on('disconnect', ()=>{ 73 | console.log('emulator disconnected'); 74 | process.exit(1); 75 | }) 76 | } 77 | 78 | // start(); 79 | 80 | // export const isEmulatorReady = () => new Promise(res => { 81 | // const t = setInterval(() => { 82 | // if (ready) { 83 | // clearInterval(t); 84 | // res(null); 85 | // } 86 | // }, 100) 87 | // }) 88 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # version: 2.1 2 | # 3 | # orbs: 4 | # win: circleci/windows@1.0.0 5 | # 6 | # jobs: 7 | # build: 8 | # executor: win/vs2019 9 | # steps: 10 | # - checkout 11 | # - run: stack --help 12 | version: 2.1 13 | 14 | orbs: 15 | win: circleci/windows@1.0.0 16 | 17 | jobs: 18 | build-windows: 19 | resource_class: small 20 | executor: 21 | name: win/vs2019 22 | shell: bash.exe 23 | steps: 24 | - checkout 25 | - run: | 26 | echo 'Building windows' 27 | curl https://get.haskellstack.org/stable/windows-x86_64.zip > stackwin.zip 28 | unzip stackwin.zip 29 | cp stackwin/stack.exe "$APPDATA/local/bin" 30 | stack install 31 | mkdir -p /tmp/workspace/build 32 | stack.exe test 33 | stack.exe build 34 | cp $(stack path --dist-dir)/build/fireward/fireward.exe /tmp/workspace/build/fireward.exe 35 | - persist_to_workspace: 36 | root: /tmp/workspace 37 | paths: 38 | - build 39 | 40 | build-macos: 41 | macos: 42 | xcode: 11.0.0 43 | steps: 44 | - checkout 45 | - run: | 46 | echo 'building mac OS' 47 | curl -sSL https://get.haskellstack.org/ | sh 48 | stack test 49 | stack build 50 | mkdir -p /tmp/workspace/build 51 | cp $(stack path --dist-dir)/build/fireward/fireward /tmp/workspace/build/fireward-mac 52 | - persist_to_workspace: 53 | root: /tmp/workspace 54 | paths: 55 | - build 56 | build-linux: 57 | docker: 58 | - image: fpco/stack-build:lts 59 | steps: 60 | - checkout 61 | - run: | 62 | echo 'building linux' 63 | curl -sSL https://get.haskellstack.org/ | sh 64 | stack test 65 | stack build 66 | stack install 67 | mkdir -p /tmp/workspace/build 68 | cp $(stack path --dist-dir)/build/fireward/fireward /tmp/workspace/build/fireward-linux 69 | cp /tmp/workspace/build/* ./npm-bin/ 70 | cd npm-bin 71 | npm version "$(firebase -V)" 72 | echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc 73 | npm publish 74 | - attach_workspace: 75 | at: /tmp/workspace 76 | workflows: 77 | build-and-deploy: 78 | jobs: 79 | - build-windows 80 | - build-macos 81 | - build-linux: 82 | requires: 83 | - build-windows 84 | - build-macos 85 | 86 | -------------------------------------------------------------------------------- /stack.yaml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by 'stack init' 2 | # 3 | # Some commonly used options have been documented as comments in this file. 4 | # For advanced use and comprehensive documentation of the format, please see: 5 | # https://docs.haskellstack.org/en/stable/yaml_configuration/ 6 | 7 | # Resolver to choose a 'specific' stackage snapshot or a compiler version. 8 | # A snapshot resolver dictates the compiler version and the set of packages 9 | # to be used for project dependencies. For example: 10 | # 11 | # resolver: lts-3.5 12 | # resolver: nightly-2015-09-21 13 | # resolver: ghc-7.10.2 14 | # resolver: ghcjs-0.1.0_ghc-7.10.2 15 | # resolver: 16 | # name: custom-snapshot 17 | # location: "./custom-snapshot.yaml" 18 | # 19 | # resolver: lts-9.20 # original resolver 20 | resolver: lts-13.27 # 7/7/2019 upgrade 21 | 22 | # User packages to be built. 23 | # Various formats can be used as shown in the example below. 24 | # 25 | # packages: 26 | # - some-directory 27 | # - https://example.com/foo/bar/baz-0.0.2.tar.gz 28 | # - location: 29 | # git: https://github.com/commercialhaskell/stack.git 30 | # commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a 31 | # - location: https://github.com/commercialhaskell/stack/commit/e7b331f14bcffb8367cd58fbfc8b40ec7642100a 32 | # extra-dep: true 33 | # subdirs: 34 | # - auto-update 35 | # - wai 36 | # 37 | # A package marked 'extra-dep: true' will only be built if demanded by a 38 | # non-dependency (i.e. a user package), and its test suites and benchmarks 39 | # will not be run. This is useful for tweaking upstream packages. 40 | packages: 41 | - . 42 | # Dependency packages to be pulled from upstream that are not in the resolver 43 | # (e.g., acme-missiles-0.3) 44 | extra-deps: [] 45 | 46 | ghc-options: 47 | "$everything": -haddock 48 | 49 | # Override default flag values for local packages and extra-deps 50 | flags: {} 51 | 52 | # Extra package databases containing global packages 53 | extra-package-dbs: [] 54 | 55 | # Control whether we use the GHC we find on the path 56 | # system-ghc: true 57 | # 58 | # Require a specific version of stack, using version ranges 59 | # require-stack-version: -any # Default 60 | # require-stack-version: ">=1.5" 61 | # 62 | # Override the architecture used by stack, especially useful on Windows 63 | # arch: i386 64 | # arch: x86_64 65 | # 66 | # Extra directories used by stack for building 67 | # extra-include-dirs: [/path/to/dir] 68 | # extra-lib-dirs: [/path/to/dir] 69 | # 70 | # Allow a newer minor version of GHC than the snapshot specifies 71 | # compiler-check: newer-minor 72 | -------------------------------------------------------------------------------- /integration/test/logic.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The logic of allow rules in combination with type checks 3 | */ 4 | import firebaseTesting = require('@firebase/rules-unit-testing'); 5 | import {RulesTestEnvironment, RulesTestContext} from '@firebase/rules-unit-testing' 6 | import {getRules} from '../util/rules'; 7 | 8 | import {User, Name} from '../wards/logic'; 9 | 10 | const WARD_NAME = 'logic'; 11 | 12 | let app: RulesTestContext; 13 | let testEnv: RulesTestEnvironment 14 | let firestore: ReturnType 15 | 16 | const projectId = WARD_NAME.toLowerCase(); 17 | const uid = '123'; 18 | 19 | before(async function() { 20 | const rules = getRules(WARD_NAME); 21 | testEnv = await firebaseTesting.initializeTestEnvironment({projectId, firestore: {rules}}); 22 | }) 23 | 24 | describe(WARD_NAME, function() { 25 | let count = 0; 26 | beforeEach(async function() { 27 | count++; 28 | }) 29 | afterEach(async () => { 30 | testEnv.clearFirestore(); 31 | }) 32 | describe(`authenticated`, function() { 33 | 34 | beforeEach(function (){ 35 | app = testEnv.authenticatedContext(uid, {}); 36 | firestore = app.firestore(); 37 | }); 38 | 39 | it(`succeeds update`, async function() { 40 | 41 | const user: User = { name: {first: 'Fire', last: "Ward"} } 42 | await firestore.collection(`users`).doc(uid).set(user); 43 | const u2: User = { name: 'FireWard' } 44 | await firebaseTesting.assertSucceeds(firestore.collection(`users`).doc(uid).set(u2)); 45 | }); 46 | 47 | it(`fails update if auth.uid!=param.uid`, async function() { 48 | 49 | const user: User = { name: {first: 'Fire', last: "Ward"} } 50 | await firestore.collection(`users`).doc('x').set(user); 51 | const u2: User = { name: 'FireWard' } 52 | await firebaseTesting.assertFails(firestore.collection(`users`).doc('x').set(u2)); 53 | }); 54 | 55 | it(`fails delete because delete rule is not specified`, async function() { 56 | const user: User = { name: 'The Warden'}; 57 | await firestore.collection(`users`).doc(uid).set(user); 58 | await firebaseTesting.assertFails(firestore.collection('users').doc(uid).delete()) 59 | }); 60 | 61 | it(`allows read of a directive without a body`, async function() { 62 | await firebaseTesting.assertSucceeds(firestore.collection('dir').doc(uid).get()) 63 | }); 64 | 65 | it(`denies write of a directive with 'false' body`, async function() { 66 | await firebaseTesting.assertFails(firestore.collection('dir').doc(uid).set({})) 67 | }); 68 | 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /integration/test/optionalTypes.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import {expect} from 'chai'; 3 | import firebaseTesting = require('@firebase/rules-unit-testing'); 4 | import {RulesTestEnvironment, RulesTestContext} from '@firebase/rules-unit-testing' 5 | import {getRules} from '../util/rules'; 6 | import {OptionalTypesExample as Test} from '../wards/optionalTypes'; 7 | 8 | const WARD_NAME = 'optionalTypes'; 9 | 10 | let app: RulesTestContext; 11 | let testEnv: RulesTestEnvironment 12 | let firestore: ReturnType 13 | 14 | const projectId = WARD_NAME.toLowerCase(); 15 | const uid = '123'; 16 | 17 | before(async function() { 18 | 19 | const rules = getRules(WARD_NAME); 20 | testEnv = await firebaseTesting.initializeTestEnvironment({projectId, firestore: {rules}}); 21 | 22 | 23 | app = testEnv.authenticatedContext(uid, {}); 24 | firestore = app.firestore(); 25 | }) 26 | 27 | describe(WARD_NAME, function() { 28 | let count = 0; 29 | beforeEach(async function() { 30 | count++; 31 | }) 32 | afterEach(async ()=>{ 33 | testEnv.clearFirestore(); 34 | }) 35 | describe(`authenticated`, function() { 36 | 37 | beforeEach(function (){ 38 | 39 | }) 40 | it(`succeeds on empty object`, async function() { 41 | const v:Test = {}; 42 | 43 | await firebaseTesting.assertSucceeds(firestore.collection(`example`).doc('x').set(v)); 44 | }) 45 | it(`succeeds on string`, async function() { 46 | const v:Test = {str: 'abc'}; 47 | await firebaseTesting.assertSucceeds(firestore.collection(`example`).doc('x').set(v)); 48 | }) 49 | it(`succeeds on a number`, async function() { 50 | const v:Test = {num: 123}; 51 | await firebaseTesting.assertSucceeds(firestore.collection(`example`).doc('x').set(v)); 52 | }) 53 | it(`succeeds on a nested with missing optional property`, async function() { 54 | const v:Test = {sub: {first: 'Fire'}}; 55 | await firebaseTesting.assertSucceeds(firestore.collection(`example`).doc('x').set(v)); 56 | }) 57 | it(`succeeds on a nested with optional property present`, async function() { 58 | const v:Test = {sub: {first: 'Fire', last: 'Ward'}}; 59 | await firebaseTesting.assertSucceeds(firestore.collection(`example`).doc('x').set(v)); 60 | }) 61 | it(`fails on nothing but extra properties`, async function() { 62 | const v = {z: 123}; 63 | await firebaseTesting.assertFails(firestore.collection(`example`).doc('x').set(v)); 64 | }) 65 | it(`fails on extra property with expected property`, async function() { 66 | const v:(Test & {z: number}) = {z: 123, str: '123'}; 67 | await firebaseTesting.assertFails(firestore.collection(`example`).doc('x').set(v)); 68 | }) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /integration/test/expressions.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import {expect} from 'chai'; 3 | import firebaseTesting = require('@firebase/rules-unit-testing'); 4 | import {RulesTestEnvironment, RulesTestContext} from '@firebase/rules-unit-testing' 5 | import {getRules} from '../util/rules'; 6 | 7 | import {} from '../wards/expressions'; 8 | 9 | 10 | const WARD_NAME = 'expressions'; 11 | 12 | const projectId = WARD_NAME.toLowerCase(); 13 | const uid = '123'; 14 | 15 | let app: RulesTestContext; 16 | let testEnv: RulesTestEnvironment 17 | let firestore: ReturnType 18 | 19 | 20 | before(async function() { 21 | const rules = getRules(WARD_NAME); 22 | testEnv = await firebaseTesting.initializeTestEnvironment({projectId, firestore: {rules}}); 23 | }) 24 | 25 | describe(WARD_NAME, function() { 26 | 27 | let count = 0; 28 | 29 | beforeEach(async function() { 30 | count++; 31 | }); 32 | 33 | afterEach(async () => { 34 | testEnv.clearFirestore(); 35 | }); 36 | 37 | describe(`authenticated`, function() { 38 | 39 | beforeEach(function() { 40 | app = testEnv.authenticatedContext(uid, {}); 41 | firestore = app.firestore(); 42 | }); 43 | 44 | it(`succeeds write for 1==1 || 2==2`, async function() { 45 | 46 | const data = {} 47 | await firebaseTesting.assertSucceeds(firestore.collection(`a`).doc(uid).set(data)); 48 | }); 49 | 50 | it(`fails write for impossible "a"!="a" || "b"!="b"`, async function() { 51 | 52 | const data = {} 53 | await firebaseTesting.assertFails(firestore.collection(`b`).doc(uid).set(data)); 54 | }); 55 | 56 | it(`succeeds write for compound rule`, async function() { 57 | 58 | const data = {test: 'f'} 59 | await firebaseTesting.assertSucceeds(firestore.collection(`c`).doc(uid).set(data)); 60 | 61 | }); 62 | 63 | it(`fails write for compound rule`, async function() { 64 | 65 | const data = {test: 'g'} 66 | await firebaseTesting.assertFails(firestore.collection(`c`).doc(uid).set(data)); 67 | 68 | }); 69 | 70 | it(`succeeds write for indexed element [1,2,3][1]==2`, async function() { 71 | 72 | const data = {test: 'f'} 73 | await firebaseTesting.assertSucceeds(firestore.collection(`d`).doc(uid).set(data)); 74 | 75 | }); 76 | 77 | it(`succeeds write for a function call`, async function() { 78 | 79 | const data = {test: 'f'} 80 | await firebaseTesting.assertSucceeds(firestore.collection(`e`).doc('hello').set(data)); 81 | 82 | }); 83 | 84 | it(`succeeds write for a regex match`, async function() { 85 | 86 | const data = {test: 'f'} 87 | await firebaseTesting.assertSucceeds(firestore.collection(`f`).doc('hello').set(data)); 88 | 89 | }); 90 | 91 | }); 92 | 93 | }); -------------------------------------------------------------------------------- /src/OptionParser.hs: -------------------------------------------------------------------------------- 1 | module OptionParser 2 | ( Options (..) 3 | , options 4 | , startOptions 5 | , getOptions 6 | ) where 7 | 8 | import System.Console.GetOpt 9 | ( OptDescr (Option) 10 | , ArgOrder (RequireOrder) 11 | , ArgDescr (NoArg, ReqArg) 12 | , getOpt 13 | , usageInfo 14 | ) 15 | 16 | import Control.Monad (when) 17 | import System.Environment (getArgs, getProgName) 18 | import System.IO 19 | ( IO(..) 20 | , readFile 21 | , writeFile 22 | , hPutStrLn 23 | , stderr 24 | , stdin 25 | , stdout 26 | ) 27 | import System.Exit (exitWith, ExitCode(..)) 28 | import Paths_fireward (version) 29 | import Data.Version (showVersion) 30 | import Data.List (intercalate) 31 | import Data.Char (isSpace) 32 | 33 | data Options = Options { optVerbose :: Bool 34 | , optLang :: String 35 | , optInput :: IO String 36 | , optOutput :: String -> IO () 37 | } 38 | 39 | startOptions :: Options 40 | startOptions = Options { optVerbose = False 41 | , optLang = "rules" 42 | , optInput = getContents 43 | , optOutput = putStr 44 | } 45 | 46 | options :: [ OptDescr (Options -> IO Options) ] 47 | options = 48 | [ Option "i" ["input"] 49 | (ReqArg 50 | (\arg opt -> return opt { optInput = readFile arg }) 51 | "FILE") 52 | "Input fireward file, instead of stdin" 53 | 54 | , Option "o" ["output"] 55 | (ReqArg 56 | (\arg opt -> return opt { optOutput = writeFile arg }) 57 | "FILE") 58 | "Output file, instead of stdout" 59 | 60 | , Option "s" ["string"] 61 | (ReqArg 62 | (\arg opt -> return opt { optInput = return arg }) 63 | "STRING") 64 | "Input string, instead of stdin or file" 65 | , Option "l" ["lang", "language"] 66 | (ReqArg 67 | (\arg opt -> return opt { optLang = arg }) 68 | "language") 69 | "Output language. One of: rules, typescript (or ts)." 70 | 71 | , Option "V" ["version"] 72 | (NoArg 73 | (\_ -> do 74 | hPutStrLn stdout (showVersion version) 75 | exitWith ExitSuccess)) 76 | "Print version" 77 | 78 | , Option "h" ["help"] 79 | (NoArg 80 | (\_ -> do 81 | prg <- getProgName 82 | hPutStrLn stderr (usageInfo prg options) 83 | exitWith ExitSuccess)) 84 | "Show this help" 85 | ] 86 | 87 | getOptions = do 88 | args <- getArgs 89 | 90 | -- Parse options, getting a list of option actions 91 | let (actions, nonOptions, errors) = getOpt RequireOrder options args 92 | 93 | -- Here we thread startOptions through all supplied option actions 94 | opts <- foldl (>>=) (return startOptions) actions 95 | return (opts, actions, nonOptions, errors) 96 | 97 | -------------------------------------------------------------------------------- /integration/test/arraysAndTuples.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The logic of allow rules in combination with type checks 3 | */ 4 | import firebaseTesting = require('@firebase/rules-unit-testing'); 5 | import {RulesTestEnvironment, RulesTestContext} from '@firebase/rules-unit-testing' 6 | import {getRules} from '../util/rules'; 7 | 8 | import {Tuple1, A, B, Booking} from '../wards/arraysAndTuples'; 9 | 10 | const WARD_NAME = 'arraysAndTuples'; 11 | 12 | const projectId = WARD_NAME.toLowerCase(); 13 | const uid = '123'; 14 | 15 | let app: RulesTestContext; 16 | let testEnv: RulesTestEnvironment; 17 | let firestore: ReturnType; 18 | 19 | 20 | before(async function() { 21 | const rules = getRules(WARD_NAME); 22 | testEnv = await firebaseTesting.initializeTestEnvironment({projectId, firestore: {rules}}); 23 | }); 24 | 25 | describe(WARD_NAME, function() { 26 | 27 | let count = 0; 28 | 29 | beforeEach(async function() { 30 | count++; 31 | }); 32 | 33 | afterEach(async () => { 34 | testEnv.clearFirestore(); 35 | }); 36 | 37 | describe(`authenticated`, function() { 38 | 39 | beforeEach(function() { 40 | app = testEnv.authenticatedContext(uid, {}); 41 | firestore = app.firestore(); 42 | }); 43 | 44 | it(`succeeds saving a tuple`, async function() { 45 | let a:A = { 46 | test: ['f', 3, 'hello', {a: 1}] 47 | } 48 | 49 | await firebaseTesting.assertSucceeds(firestore.collection(`a`).doc(uid).set(a)); 50 | }); 51 | 52 | it(`fails saving a tuple with optionals that's too short`, async function() { 53 | let x = { 54 | test: [1] 55 | } 56 | 57 | await firebaseTesting.assertFails(firestore.collection(`b`).doc(uid).set(x)); 58 | }); 59 | 60 | it(`succeeds saving a tuple with optionals that's min len`, async function() { 61 | let x:B = { 62 | test: [1,2] 63 | } 64 | 65 | await firebaseTesting.assertSucceeds(firestore.collection(`b`).doc(uid).set(x)); 66 | }); 67 | 68 | it(`succeeds saving a tuple with optionals that's full len`, async function() { 69 | let x:B = { 70 | test: [1,2,3,4] 71 | } 72 | 73 | await firebaseTesting.assertSucceeds(firestore.collection(`b`).doc(uid).set(x)); 74 | }); 75 | 76 | it(`fails saving a tuple with optionals that's too long`, async function() { 77 | let x = { 78 | test: [1,2,3,4,5] 79 | } 80 | 81 | await firebaseTesting.assertFails(firestore.collection(`b`).doc(uid).set(x)); 82 | }); 83 | 84 | it(`saves and updates all custom type rule; issue #18`, async function() { 85 | let x: Booking = { 86 | sessions: [{dayOfWeek: 2}] 87 | } 88 | await firebaseTesting.assertSucceeds(firestore.collection(`book`).doc(uid).set(x)); 89 | x.sessions.push({dayOfWeek: 33}) 90 | await firebaseTesting.assertSucceeds(firestore.collection(`book`).doc(uid).set(x)); 91 | let y = { 92 | sessions: [{dayOfWeek: 2},{dayOfWeek: 22},{dayOfWeek: 222}] 93 | } 94 | 95 | await firebaseTesting.assertFails(firestore.collection(`book`).doc(uid).set(y)); 96 | }); 97 | 98 | }); 99 | 100 | }); -------------------------------------------------------------------------------- /src/Combinators.hs: -------------------------------------------------------------------------------- 1 | module Combinators ( 2 | _alpha, 3 | _alphaNum, 4 | _varStart, 5 | _varName, 6 | separated, 7 | escape, 8 | _getWhile, 9 | _stringD, 10 | _string, 11 | readDef, 12 | _natural, 13 | _float, 14 | _int, 15 | void, 16 | altr, 17 | _terminated, 18 | _concat, 19 | _map 20 | ) where 21 | 22 | import Parser 23 | import Control.Applicative (optional, empty) 24 | import Data.Char (isSpace) 25 | import Text.Read (readMaybe) 26 | import Data.List (intercalate) 27 | 28 | _concat :: [Parser String] -> String -> Parser String 29 | _concat ps sep = intercalate sep <$> sequence ps 30 | 31 | _alpha = lower <|> upper 32 | _alphaNum = _alpha <|> digit 33 | _varStart = _alpha <|> charIn "_$" 34 | _varName :: Parser String 35 | _varName = do 36 | c <- _varStart 37 | rest <- many (_alphaNum <|> charIn "_$") 38 | return (c:rest) 39 | 40 | separated = manywith . symbol 41 | 42 | escape :: Char -> Parser String 43 | escape c = do 44 | char '\\' 45 | r <- sat (==c) 46 | return ('\\':[r]) 47 | 48 | altr :: [Parser a] -> Parser a 49 | altr [] = empty 50 | altr (p:ps) = p <|> altr ps 51 | 52 | _getWhile p = many $ sat p 53 | 54 | 55 | _stringD :: Char -> Parser String 56 | _stringD delim = do 57 | char delim 58 | a <- many $ (_const ('\\':[delim]) <|> (:[]) <$> sat (/=delim)) 59 | require "unterminated string" $ char delim 60 | return $ concat (([delim]:a) ++ [[delim]]) 61 | 62 | _string :: Parser String 63 | _string = _stringD '"' <|> _stringD '\'' 64 | 65 | readDef :: (Read a) => a -> String -> a 66 | readDef def s = case reads s of 67 | [(x, "")] -> x 68 | _ -> def 69 | 70 | _natural :: Parser Int 71 | _natural = do -- a natural number 72 | str <- some digit 73 | let n = readDef (-1) str 74 | guardWith "expected an integer" (n /= -1) 75 | return n 76 | 77 | void :: Parser a -> Parser () 78 | void p = p >> return () 79 | 80 | 81 | _terminated parser terminator = do 82 | v <- parser 83 | terminator 84 | return v 85 | 86 | toFloat :: Int -> Float 87 | toFloat = fromIntegral -- x = x * 1.0 :: Double 88 | 89 | _float :: Parser Float 90 | _float = do 91 | neg <- optional $ symbol "-" 92 | optional space 93 | left <- toFloat <$> _natural 94 | decMaybe <- optional _dec 95 | let decStr = maybe "0" id decMaybe :: String 96 | let dec = readDef 0 decStr :: Float 97 | let right = shiftDec dec 98 | return $ (left + right) * (if neg==Nothing then 1 else -1) 99 | where 100 | shiftDec x = if x >= 1 then shiftDec (x/10) else x 101 | readRight x = (readDef 0 x) / (10.0 ^ (length x)) :: Double 102 | _dec = do 103 | symbol "." 104 | some digit 105 | 106 | _int :: Parser Int 107 | _int = do 108 | neg <- optional $ symbol "-" 109 | num <- _natural 110 | return $ if neg == Nothing then num else (-1 * num) 111 | 112 | _map :: [(String, a)] -> Parser a 113 | _map xs = do 114 | -- (>>) :: Monad m => m a -> m b -> m b 115 | -- symbol :: String -> Parser () 116 | -- return :: a -> m a 117 | -- (>>) (String -> m a) (b -> m b) -> m b 118 | -- (>>) (a->b) (a->c) :: 119 | -- let _c = symbol >> return 120 | let _c = \s -> symbol s >> return s 121 | key <- altr $ fmap (_c . fst) xs 122 | let val = lookup key xs 123 | maybe unparsable return val 124 | 125 | -------------------------------------------------------------------------------- /integration/test/validations.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The logic of allow rules in combination with type checks 3 | */ 4 | import firebaseTesting = require('@firebase/rules-unit-testing'); 5 | import {RulesTestEnvironment, RulesTestContext} from '@firebase/rules-unit-testing' 6 | import {getRules} from '../util/rules'; 7 | 8 | import {Pilot} from '../wards/validations'; 9 | 10 | 11 | const WARD_NAME = 'validations'; 12 | let firestore: ReturnType; 13 | let app: RulesTestContext; 14 | let testEnv: RulesTestEnvironment; 15 | 16 | const projectId = WARD_NAME.toLowerCase(); 17 | const uid = '123'; 18 | 19 | before(async function() { 20 | const rules = getRules(WARD_NAME); 21 | testEnv = await firebaseTesting.initializeTestEnvironment({projectId, firestore: {rules}}) 22 | }); 23 | 24 | describe(WARD_NAME, function() { 25 | 26 | let count = 0; 27 | 28 | beforeEach(async function() { 29 | count++; 30 | }); 31 | 32 | afterEach(async () => { 33 | testEnv.clearFirestore(); 34 | }); 35 | 36 | describe(`authenticated`, function() { 37 | 38 | beforeEach(function (){ 39 | app = testEnv.authenticatedContext(uid, {}); 40 | firestore = app.firestore(); 41 | }); 42 | 43 | it(`succeeds saving a validated record`, async function() { 44 | 45 | const p:Pilot = { 46 | experience: 5, 47 | name: 'Capt. Rex Kramer' 48 | } 49 | await firebaseTesting.assertSucceeds(firestore.collection(`pilots`).doc(uid).set(p)); 50 | }); 51 | 52 | it(`fails saving a with data validation`, async function() { 53 | 54 | const p:Pilot = { 55 | experience: 3, 56 | name: 'Capt. Rex Kramer' 57 | } 58 | await firebaseTesting.assertFails(firestore.collection(`pilots`).doc(uid).set(p)); 59 | }); 60 | 61 | it(`fails saving a with data and prev comparison validation`, async function() { 62 | 63 | const p1:Pilot = { 64 | experience: 5, 65 | name: 'Capt. Rex Kramer' 66 | } 67 | await firebaseTesting.assertSucceeds(firestore.collection(`pilots`).doc(uid).set(p1)); 68 | const p2:Pilot = { 69 | experience: 4, 70 | name: 'Capt. Rex Kramer' 71 | } 72 | await firebaseTesting.assertFails(firestore.collection(`pilots`).doc(uid).set(p2)); 73 | }); 74 | 75 | it(`succeeds saving a with data and prev comparison validation`, async function() { 76 | 77 | const p1:Pilot = { 78 | experience: 5, 79 | name: 'Capt. Kramer' 80 | } 81 | await firebaseTesting.assertSucceeds(firestore.collection(`pilots`).doc(uid).set(p1)); 82 | const p2:Pilot = { 83 | experience: 5, 84 | name: 'Capt. Rex Kramer' 85 | } 86 | await firebaseTesting.assertSucceeds(firestore.collection(`pilots`).doc(uid).set(p2)); 87 | }); 88 | 89 | it(`fails for Otto`, async function() { 90 | 91 | const p1:Pilot = { 92 | experience: 5, 93 | name: 'Otto' 94 | } 95 | await firebaseTesting.assertFails(firestore.collection(`pilots`).doc(uid).set(p1)); 96 | }); 97 | 98 | it(`Capt. Clarence Oveur fails to return`, async function() { 99 | 100 | const p1:Pilot = { 101 | experience: 5, 102 | name: 'Capt. Clarence Oveur' 103 | } 104 | await firebaseTesting.assertSucceeds(firestore.collection(`pilots`).doc(uid).set(p1)); 105 | }); 106 | 107 | }); 108 | 109 | }); -------------------------------------------------------------------------------- /test/TSGeneratorSpec.hs: -------------------------------------------------------------------------------- 1 | module TSGeneratorSpec (main, spec, stdTypes) where 2 | 3 | import Parser (trim) 4 | import TSGenerator 5 | 6 | import Data.Char (isDigit) 7 | import Control.Applicative 8 | import Control.Monad 9 | import Control.Monad.IO.Class (liftIO) 10 | import Test.Hspec 11 | import Test.QuickCheck 12 | import Debug.Trace (trace) 13 | 14 | main :: IO () 15 | main = hspec spec 16 | 17 | esc c r (a:b:s) = if [a,b]==['\\', c] 18 | then r:(esc c r s) else a:(esc c r (b:s)) 19 | esc c r s = s 20 | repN = esc 'n' '\n' 21 | repQ = esc '"' '\"' 22 | repA = repN . repQ 23 | showN :: Show a => a -> String 24 | showN = repA . show 25 | showE (Right x) = "Right " ++ repA x 26 | showE (Left x) = "Left " ++ repA x 27 | g = showE . TSGenerator.generate 28 | gt z = (\x->trace (showN x) x) (g z) 29 | gu = g . trim . unlines 30 | r = ("Right " ++) . repA 31 | ru = r . trim . unlines 32 | timestamp = "Types['timestamp']" 33 | 34 | 35 | spec :: Spec 36 | spec = do 37 | describe "Typescript Generator" $ do 38 | it "generates a simple thing" $ 39 | g "type X = Y | {a:int}" 40 | `shouldBe` ru 41 | [ stdTypes ++ "export type X = Y | {" 42 | , " a: Types['number']" 43 | , "}" 44 | ] 45 | it "generates a simple array" $ do 46 | g "type X = {a:string[]}" `shouldBe` ru 47 | [ stdTypes ++ "export type X = {" 48 | , " a: string[]" 49 | , "}" 50 | ] 51 | it "generates a 2-tuple array" $ do 52 | g "type X = {a:string[2]}" `shouldBe` ru 53 | [ stdTypes ++ "export type X = {" 54 | , " a: [string?, string?]" 55 | , "}" 56 | ] 57 | it "generates a 3-tuple" $ do 58 | g "type X = {a: [string, Z, {b: 1}]}" `shouldBe` ru 59 | [ stdTypes ++ "export type X = {" 60 | , " a: [string, Z, {\n b: 1\n}]" 61 | , "}" 62 | ] 63 | it "generates a grouped array" $ do 64 | g "type X = (string | float)[]" `shouldBe` ru 65 | [ stdTypes ++ "export type X = (string | Types['number'])[]" ] 66 | it "generates the any type" $ do 67 | g "type X = {a:any}" `shouldBe` ru 68 | [ stdTypes ++ "export type X = {\n a: any\n}" ] 69 | it "eats line comments" $ 70 | gu 71 | [ "type X = { // type X" 72 | , "// a line of comment" 73 | , " a: string // a is a string" 74 | , "}" 75 | ] 76 | `shouldBe` ru 77 | [ stdTypes ++ "export type X = {" 78 | , " a: string" 79 | , "}" 80 | ]; 81 | 82 | it "generates a compound thing" $ 83 | gu 84 | [ "type X = Y | {a:int}" 85 | , "type Compound = {" 86 | , " a: timestamp" 87 | , " b: {" 88 | , " ba: int" 89 | , " bb: {" 90 | , " bba: string" 91 | , " }" 92 | , " }" 93 | , "}" 94 | ] 95 | `shouldBe` ru 96 | [ stdTypes ++ "export type X = Y | {" 97 | , " a: Types['number']" 98 | , "}" 99 | , "export type Compound = {" 100 | , " a: " ++ timestamp 101 | , " b: {" 102 | , " ba: Types['number']" 103 | , " bb: {" 104 | , " bba: string" 105 | , " }" 106 | , " }" 107 | , "}" 108 | ] 109 | 110 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build, release and publish 2 | on: 3 | push: 4 | # branches: 5 | # - master 6 | tags: 7 | - '[0-9]+.[0-9]+.[0-9]+' 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | os: [ windows-latest, ubuntu-latest, macos-latest ] 14 | steps: 15 | - uses: actions/checkout@v2 16 | # setup haskell actions https://kodimensional.dev/github-actions 17 | - uses: actions/setup-haskell@v1.1.4 18 | name: Setup Haskell Stack 19 | with: 20 | ghc-version: 8.8.4 21 | stack-version: 2.3.1 22 | - uses: actions/cache@v2.1.3 23 | name: Cache ~/.stack 24 | with: 25 | path: ~/.stack 26 | key: ${{ runner.os }}-stack 27 | 28 | - name: Build 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | shell: bash 32 | run: | 33 | version=$(git describe --tags `git rev-list --tags --max-count=1`) 34 | stack --no-terminal install --bench --no-run-benchmarks --no-run-tests 35 | 36 | osname=$(echo "$RUNNER_OS" | awk '{print tolower($0)}' | sed 's/macos/osx/') 37 | if [ "$osname" = "windows" ]; then 38 | DEPLOY_FILE=$(stack path --local-bin)\\fireward.exe 39 | # cp "$(stack path --local-bin)\\fireward" "$DEPLOY_FILE" # don't copy anything; it's already there 40 | else 41 | DEPLOY_FILE=$(stack path --local-bin)/fireward-${osname} 42 | cp "$(stack path --local-bin)/fireward" "$DEPLOY_FILE" # don't clobber files from different os's 43 | fi 44 | gh release view $version || gh release create $version --title "$version" --notes "${{ github.event.head_commit.message }}" --draft 45 | gh release upload $version "${DEPLOY_FILE}#${RUNNER_OS}" 46 | 47 | release: 48 | needs: build 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v2 52 | - name: get-release 53 | id: get-release 54 | run: | 55 | version=$(git describe --tags `git rev-list --tags --max-count=1`) 56 | tagid=$(gh release view $version | grep 'url:' | head -n 1 | sed -E 's/.*releases.tag.(.*)/\1/') 57 | gh api repos/$GITHUB_REPOSITORY/releases/tags/$tagid > draft.json 58 | releaseid=$(node -e "console.log(require('./draft.json').id)") 59 | echo "::set-output name=release_id::$releaseid" 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | - name: Publish release 63 | uses: StuYarrow/publish-release@v1 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | with: 67 | id: ${{ steps.get-release.outputs.release_id }} 68 | 69 | publish: 70 | needs: release 71 | runs-on: ubuntu-latest 72 | steps: 73 | - uses: actions/checkout@v2 74 | - uses: actions/setup-node@v2 75 | with: 76 | node-version: '10' 77 | - name: Publish release 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 81 | run: | 82 | 83 | cd npm-bin 84 | 85 | v=$(git describe --tags `git rev-list --tags --max-count=1`) 86 | files=(fireward-linux fireward-osx fireward.exe) 87 | 88 | for f in ${files[*]}; do 89 | if [ -f "$f" ]; then 90 | rm $f 91 | fi 92 | done 93 | 94 | for f in ${files[*]}; do 95 | echo "downloading $f" 96 | curl -L https://github.com/bijoutrouvaille/fireward/releases/download/$v/$f -o $f 97 | chmod 755 $f 98 | done 99 | 100 | cp ../readme.md ./readme.md 101 | 102 | echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" > .npmrc 103 | npm publish 104 | -------------------------------------------------------------------------------- /test/ParserSpec.hs: -------------------------------------------------------------------------------- 1 | module ParserSpec (main, spec) where 2 | 3 | import Data.Char (isDigit) 4 | import Control.Applicative 5 | import Control.Monad 6 | import Test.Hspec 7 | import Test.QuickCheck 8 | import Data.Char (isSpace) 9 | import Parser 10 | 11 | with x f = f x 12 | 13 | _apply p s = res (apply p s) 14 | where 15 | res (Right (ParserSuccess 16 | { parserResult = x 17 | , unparsed = u 18 | , parserLine = _ 19 | , parserCol = _ 20 | , parserWarnings = w 21 | })) = Right (x, u) 22 | res (Left x) = Left x 23 | 24 | 25 | main :: IO () 26 | main = hspec spec 27 | 28 | spec :: Spec 29 | spec = do 30 | describe "getc" $ do 31 | it "gets first char" $ 32 | _apply getc "hello" `shouldBe` Right ('h', "ello") 33 | describe "sat" $ do 34 | it "gets first char if happy" $ 35 | _apply (sat isDigit) "1z" `shouldBe` Right ('1', "z") 36 | it "gets a string until char" $ 37 | _apply (many $ sat (/=';')) "hello : ;" `shouldBe` Right ("hello : ", ";") 38 | -- describe "space" $ do 39 | -- it "eats line comments" $ do 40 | -- let s = many $ sat (/=';') 41 | -- _apply (many s) "hello // this is a comment\nbye" `shouldBe` [(["hello", "bye"], "")] 42 | describe "char" $ do 43 | it "gets char" $ property $ 44 | \c-> _apply (char c) (c:"dioscuri") === Right (c, "dioscuri") 45 | describe "digit" $ do 46 | it "gets a digit" $ 47 | _apply digit "2b" `shouldBe` Right ('2', "b") 48 | describe "<|>" $ do 49 | it "returns empty if not matched" $ 50 | _apply (digit <|> empty) "a2" `shouldBe` Left Nothing 51 | it "returns a match if matched" $ 52 | _apply (digit <|> empty) "2a" `shouldBe` Right ('2', "a") 53 | 54 | describe "some" $ do 55 | it "returns some digits" $ 56 | _apply (some digit) "123x" `shouldBe` Right ("123", "x") 57 | it "alternates if some is not matched" $ 58 | _apply (some digit <|> some lower) "b123" `shouldBe` Right ("b", "123") 59 | 60 | describe "many" $ do 61 | it "returns none if nothing is matched" $ 62 | _apply (many digit) "abc" `shouldBe` Right ([], "abc") 63 | describe "optional'" $ do 64 | it "returns the item if matched" $ 65 | _apply (optional' . some $ digit) "1a" `shouldBe` Right ("1", "a") 66 | it "returns the none if none matched" $ 67 | _apply (optional' . some $ digit) "a1" `shouldBe` Right ("", "a1") 68 | 69 | describe "token" $ do 70 | it "parses a spaced number" $ 71 | _apply (token $ some digit) " 123x" `shouldBe` Right ("123", "x") 72 | describe "symbol" $ do 73 | it "parses a spaced string" $ 74 | _apply (symbol "hello") " hello " `shouldBe` Right ((), " ") 75 | 76 | describe "grouped" $ do 77 | it "parses a number surrounded by brackets" $ 78 | _apply (grouped "[[" "]]" $ some digit) " [[123 ]] " `shouldBe` Right ("123", " ") 79 | it "parses a group within group of same brackets" $ 80 | _apply (grouped "{" "}" $ do { 81 | symbol "+"; 82 | grouped "{" "}" (some digit); 83 | }) "{ +{123}}" `shouldBe` Right ("123", "") 84 | it "parses spaces and new lines" $ 85 | _apply (grouped "{" "}" (token digit)) (unlines 86 | [ " { " 87 | , " 3 " 88 | , " } " 89 | ]) `shouldBe` Right ('3', " \n") 90 | 91 | describe "whileNot" $ do 92 | it "parses until a keyword" $ do 93 | _apply (whileNot $ symbol "end") "123 hello, end" `shouldBe` Right ("123 hello,", " end") 94 | it "parses alternatives until" $ 95 | _apply (whileNot $ symbol "end" <|> (char ';' >> return ())) "he llo;, end" `shouldBe` Right ("he llo", ";, end") 96 | describe "oneof" $ do 97 | it "parses one of" $ forAll (elements "hzu") $ 98 | \c -> _apply (charIn "hzu") (c:"hello") === Right (c, "hello") 99 | it "parses none of" $ forAll (elements "hzu") $ 100 | \c -> _apply (charIn "abc") (c:"hello") === Left Nothing 101 | 102 | describe "enum" $ do 103 | it "parses" $ 104 | _apply (enum ["aa", "bb"]) "aa" `shouldBe` Right ("aa", "") 105 | it "parses manywith" $ 106 | _apply (manywith (symbol ",") $ enum [ "aa", "bb" ]) "aa,bb , bb, aa" 107 | `shouldBe` Right (["aa","bb","bb","aa"], "") 108 | describe "somewith" $ do 109 | it "parses into an array" $ 110 | _apply (somewith (symbol ",") (token digit)) " 3, 4 , 5,6,3 ff+" `shouldBe` Right ("34563", " ff+") 111 | describe "require" $ do 112 | it "fails on missing requirements" $ do 113 | let p = do symbol "start" 114 | num <- token $ some digit 115 | require "expecting the end" $ symbol "end" 116 | return num 117 | 118 | _apply p "start\n123" `shouldBe` failure ("expecting the end", 1, 3) 119 | 120 | it "fails on a bad alternative" $ do 121 | let q = symbol "{" >> require "digit required" (some digit) 122 | let p = string "{" >> return "3" 123 | _apply (q <|> p) "{" `shouldBe` failure ("digit required", 0, 1) 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /src/TSGenerator.hs: -------------------------------------------------------------------------------- 1 | module TSGenerator 2 | ( Error (..) 3 | , loc 4 | , generate 5 | , stdTypes 6 | ) where 7 | 8 | 9 | import Parser 10 | import RuleParser 11 | 12 | import Loc (loc, Loc(..)) 13 | import Error (Error(..)) 14 | 15 | import Data.List (findIndices, intersperse, intercalate, stripPrefix) 16 | import Data.Char (toUpper) 17 | import Control.Monad (ap) 18 | 19 | capitalize :: [Char] -> [Char] 20 | capitalize "" = "" 21 | capitalize (c:cs) = (toUpper c) : cs 22 | 23 | joinLines :: [[Char]] -> [Char] 24 | joinLines = intercalate "\n" 25 | 26 | indent :: Int -> [Char] 27 | indent n = take n $ repeat ' ' 28 | indentBy :: Int -> [Char] -> [Char] 29 | indentBy n = (indent n ++) 30 | 31 | block :: Int -> [String] -> String 32 | block ind items = joinLines 33 | [ "{" 34 | , joinLines $ (indent (ind + 2) ++) <$> items 35 | , indent ind ++ "}" 36 | ] 37 | 38 | 39 | natives :: [([Char], [Char])] 40 | natives = 41 | [ ("int", "Types['number']") 42 | , ("float", "Types['number']") 43 | , ("timestamp", "Types['timestamp']") 44 | , ("latlng", "WardGeoPoint") 45 | , ("bool", "boolean") 46 | , ("null", "null") 47 | , ("map", "Record") 48 | , ("string", "string") 49 | , ("any", "any") 50 | ] 51 | 52 | 53 | 54 | fieldValueType = "export type WardFieldValue = { isEqual: (other: WardFieldValue) => boolean };" 55 | timestampType = "export type WardTimestamp = {seconds: number, nanoseconds: number, toDate: ()=>Date, isEqual: (other: WardTimestamp)=>boolean, toMillis: ()=>number, valueOf: ()=>string};" 56 | timestampTypeCheck = "export function isTimestamp(v: any): v is WardTimestamp { return !!v && (typeof v=='object') && !!v.toDate && !!v.toMillis && (typeof v.nanoseconds=='number') && (typeof v.seconds=='number')};" 57 | geoPointType = "export type WardGeoPoint = { latitude: number, longitude: number, isEqual: (other: WardGeoPoint)=>boolean, toJSON: ()=>{latitude: number, longitude: number} }" 58 | geoPointTypeCheck = "export function isGeoPoint(v: any): v is WardGeoPoint { return !!v && (typeof v=='object') && (typeof v.isEqual=='function') && (typeof v.latitude=='number') && (typeof v.longitude=='number') };" 59 | 60 | inputTypes :: [Char] 61 | inputTypes = "export type FirewardOutput = /** what you get from DB */ { timestamp: WardTimestamp|null; number: number; };" 62 | outputTypes :: [Char] 63 | outputTypes = "export type FirewardInput = /** what you send to DB */ { timestamp: WardTimestamp|Date|WardFieldValue; number: number|WardFieldValue; };" 64 | firewardTypes = "export type FirewardTypes = FirewardInput | FirewardOutput;" 65 | 66 | stdTypes :: [Char] 67 | stdTypes = (intercalate "\n" 68 | [ fieldValueType 69 | , timestampType 70 | , timestampTypeCheck 71 | , geoPointType 72 | , geoPointTypeCheck 73 | , inputTypes 74 | , outputTypes 75 | , firewardTypes 76 | ]) ++ "\n\n" 77 | 78 | fork f g a = (f a) (g a) 79 | typeBlock :: Int -> TypeDef -> String 80 | typeBlock ind (TypeDef fields _) = block ind $ 81 | f <$> fields 82 | where 83 | f (Field r name refs c) = name ++ (if r then "" else "?") ++ ": " ++ typeRefList (ind + 2) refs 84 | typeRefList :: Int -> [TypeRef] -> String 85 | typeRefList ind refs = 86 | trim . intercalate " | " $ ref <$> refs 87 | where 88 | -- tldr: q = f <*> g === ap f g === q x = (f x) (g x). 89 | -- convertToNative = flip maybe id <*> flip lookup natives 90 | convertToNative name = maybe (name ++ "") id (lookup name natives) 91 | ref :: TypeRef -> String 92 | ref (LiteralTypeRef value) = value 93 | ref (ListTypeRef r) = ref r ++ "[]" 94 | ref (GroupedTypeRef refs) = "(" ++ typeRefList 0 refs ++ ")" 95 | ref (TupleTypeRef elems) = "[" ++ intercalate ", " (fmap fromTupleElem elems) ++ "]" 96 | ref (TypeNameRef name ) = convertToNative name 97 | ref (InlineTypeDef def) = typeBlock ind def 98 | 99 | 100 | fromTupleElem (req, refs) = 101 | if req then typeRefList 0 refs 102 | else if length refs == 1 103 | then typeRefList 0 refs ++ "?" 104 | else "(" ++ typeRefList 0 refs ++ ")?" 105 | 106 | 107 | topLevelType name refs = "export type " ++ name ++ " = " ++ typeRefList 0 refs 108 | 109 | gen :: [TopLevel] -> Either String String 110 | gen tops = result where 111 | result = Right $ stdTypes ++ joinLines strings 112 | strings = g <$> tops 113 | g (TopLevelType name refs) = topLevelType name refs 114 | g _ = "" 115 | 116 | 117 | generate :: String -> Either String String 118 | generate s = result $ parseRules s where 119 | result (Left Nothing) = Left ("Unexpected parser error.") 120 | result (Left (Just (e,l,c))) = Left (e ++ "\n on " ++printLoc l c) 121 | result (Right (ParserSuccess 122 | { parserResult = tops 123 | , unparsed = "" 124 | , parserLine = _ 125 | , parserCol = _ 126 | , parserWarnings = w 127 | })) = gen tops 128 | result (Right (ParserSuccess 129 | { parserResult = tops 130 | , unparsed = unparsed 131 | , parserLine = l 132 | , parserCol = c 133 | , parserWarnings = w 134 | })) = Left ("Unexpected input on " ++ printLoc l c) 135 | printLoc l c = "line " ++ show (l+1) ++", column "++show (c+1) 136 | -------------------------------------------------------------------------------- /integration/test/const.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The logic of allow rules in combination with type checks 3 | */ 4 | // import firebase = require('firebase'); 5 | import * as firebase from 'firebase/app' 6 | import {getFirestore} from 'firebase/firestore'; 7 | import firebaseTesting = require('@firebase/rules-unit-testing'); 8 | import {RulesTestEnvironment, RulesTestContext} from '@firebase/rules-unit-testing' 9 | import {getRules} from '../util/rules'; 10 | import {StrTest, IntTest, FloatTest, MapTest, BoolTest, OptTest} from '../wards/const'; 11 | 12 | const WARD_NAME = 'const'; 13 | 14 | const projectId = WARD_NAME.toLowerCase(); 15 | const uid = '123'; 16 | 17 | let app: RulesTestContext; 18 | let testEnv: RulesTestEnvironment 19 | let firestore: ReturnType 20 | 21 | before(async function() { 22 | const rules = getRules(WARD_NAME); 23 | testEnv = await firebaseTesting.initializeTestEnvironment({projectId, firestore: {rules}}); 24 | }) 25 | 26 | describe(WARD_NAME, function() { 27 | 28 | let count = 0; 29 | 30 | beforeEach(async function() { 31 | count++; 32 | }); 33 | 34 | afterEach(async () => { 35 | testEnv.clearFirestore(); 36 | }); 37 | 38 | describe(`authenticated`, function() { 39 | 40 | beforeEach(function() { 41 | app = testEnv.authenticatedContext(uid, {}); 42 | firestore = app.firestore(); 43 | }); 44 | 45 | it(`fails updating of const str`, async function() { 46 | 47 | const a: StrTest = { test: '123', name: "Ward" } 48 | await firestore.collection(`str`).doc(uid).set(a); 49 | const b: StrTest = { test: '234', name: "Ward" } 50 | await firebaseTesting.assertFails(firestore.collection(`str`).doc(uid).set(b)); 51 | }); 52 | 53 | it(`succeeds update if const str is the same`, async function() { 54 | 55 | const a: StrTest = { test: '123', name: "Ward" } 56 | await firestore.collection(`str`).doc(uid).set(a); 57 | const b: StrTest = { test: '123', name: "Fire" } 58 | await firebaseTesting.assertSucceeds(firestore.collection(`str`).doc(uid).set(b)); 59 | }); 60 | 61 | it(`fails updating of const int`, async function() { 62 | 63 | const a: IntTest = { test: 123, name: "Ward" } 64 | await firestore.collection(`int`).doc(uid).set(a); 65 | const b: IntTest = { test: 234, name: "Ward" } 66 | await firebaseTesting.assertFails(firestore.collection(`int`).doc(uid).set(b)); 67 | }); 68 | 69 | it(`succeeds update if const int is the same`, async function() { 70 | 71 | const a: IntTest = { test: 123, name: "Ward" } 72 | await firestore.collection(`int`).doc(uid).set(a); 73 | const b: IntTest = { test: 123, name: "Fire" } 74 | await firebaseTesting.assertSucceeds(firestore.collection(`int`).doc(uid).set(b)); 75 | }); 76 | 77 | it(`fails updating of const float`, async function() { 78 | 79 | const a: FloatTest = { test: 123.5, name: "Ward" } 80 | await firestore.collection(`float`).doc(uid).set(a); 81 | const b: FloatTest = { test: 234.1, name: "Ward" } 82 | await firebaseTesting.assertFails(firestore.collection(`float`).doc(uid).set(b)); 83 | }); 84 | 85 | it(`succeeds update if const float is the same`, async function() { 86 | 87 | const a: FloatTest = { test: 123.5, name: "Ward" } 88 | await firestore.collection(`float`).doc(uid).set(a); 89 | const b: FloatTest = { test: 123.5, name: "Fire" } 90 | await firebaseTesting.assertSucceeds(firestore.collection(`float`).doc(uid).set(b)); 91 | }); 92 | it(`fails updating of const bool`, async function() { 93 | 94 | const a: BoolTest = { test: true, name: "Ward" } 95 | await firestore.collection(`bool`).doc(uid).set(a); 96 | const b: BoolTest = { test: false, name: "Ward" } 97 | await firebaseTesting.assertFails(firestore.collection(`bool`).doc(uid).set(b)); 98 | }); 99 | 100 | it(`succeeds update if const bool true is the same`, async function() { 101 | 102 | const a: BoolTest = { test: true, name: "Ward" } 103 | await firestore.collection(`bool`).doc(uid).set(a); 104 | const b: BoolTest = { test: true, name: "Fire" } 105 | await firebaseTesting.assertSucceeds(firestore.collection(`bool`).doc(uid).set(b)); 106 | }); 107 | 108 | it(`succeeds update if const bool false is the same`, async function() { 109 | 110 | const a: BoolTest = { test: false, name: "Ward" } 111 | await firestore.collection(`bool`).doc(uid).set(a); 112 | const b: BoolTest = { test: false, name: "Fire" } 113 | await firebaseTesting.assertSucceeds(firestore.collection(`bool`).doc(uid).set(b)); 114 | }); 115 | 116 | it(`fails updating of const map`, async function() { 117 | 118 | const a: MapTest = { test: {a:1}, name: "Ward" } 119 | await firestore.collection(`map`).doc(uid).set(a); 120 | const b: MapTest = { test: {a:2}, name: "Ward" } 121 | await firebaseTesting.assertFails(firestore.collection(`map`).doc(uid).set(b)); 122 | }); 123 | 124 | it(`succeeds update if const map is the same`, async function() { 125 | 126 | const a: MapTest = { test: {a:1}, name: "Ward" } 127 | await firestore.collection(`map`).doc(uid).set(a); 128 | const b: MapTest = { test: {a:1}, name: "Fire" } 129 | await firebaseTesting.assertSucceeds(firestore.collection(`map`).doc(uid).set(b)); 130 | }); 131 | 132 | it(`fails updating of optional const string`, async function() { 133 | 134 | const a: OptTest = { test: '123', name: "Ward" } 135 | await firestore.collection(`opt`).doc(uid).set(a); 136 | const b: OptTest = { test: '234', name: "Ward" } 137 | await firebaseTesting.assertFails(firestore.collection(`opt`).doc(uid).set(b)); 138 | }); 139 | 140 | it(`succeeds update if const opt is the same`, async function() { 141 | 142 | const a: OptTest = { test: '123', name: "Ward" } 143 | await firestore.collection(`opt`).doc(uid).set(a); 144 | const b: OptTest = { test: '123', name: "Fire" } 145 | await firebaseTesting.assertSucceeds(firestore.collection(`opt`).doc(uid).set(b)); 146 | }); 147 | 148 | it(`succeeds update if const opt was missing the first time`, async function() { 149 | 150 | const a: OptTest = { name: "Ward" } 151 | await firestore.collection(`opt`).doc(uid).set(a); 152 | const b: OptTest = { test: '123', name: "Fire" } 153 | await firebaseTesting.assertSucceeds(firestore.collection(`opt`).doc(uid).set(b)); 154 | }); 155 | 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /test/ExprParserSpec.hs: -------------------------------------------------------------------------------- 1 | module ExprParserSpec (main, spec) where 2 | 3 | import RuleParser 4 | 5 | import Data.Char (isDigit) 6 | import Control.Applicative 7 | import Control.Monad 8 | import Test.Hspec 9 | import Test.QuickCheck 10 | import Debug.Trace (trace) 11 | import ExprParser 12 | import ExprPrinter 13 | import Parser (ParserSuccess(..)) 14 | 15 | p = printExpr 16 | 17 | pos c l = show c ++ ":" ++ show l 18 | etxt e c l = e ++ " " ++ pos c l 19 | err (Just (e, c, l)) = etxt e c l 20 | err Nothing = "Nothing" 21 | 22 | ex e = print tree where 23 | tree = parse e 24 | print (Left e) = err e 25 | print (Right x) = printExpr x 26 | 27 | parse s = d $ apply expr s where 28 | d (Left err) = Left err 29 | d (Right (ParserSuccess 30 | { parserResult = x 31 | , unparsed = z 32 | , parserLine = _ 33 | , parserCol = _ 34 | , parserWarnings = w 35 | })) = if z=="" then Right x else Left (Just (z, 1, 1)) 36 | -- d (Right (x, z, _, _)) = if z=="" then Right x else Left (Just (z, 1, 1)) 37 | 38 | main :: IO () 39 | main = hspec spec 40 | 41 | spec :: Spec 42 | spec = do 43 | describe "Expression Parser" $ do 44 | it "passes for now" $ 45 | shouldBe 1 1 46 | it "parses an int" $ do 47 | parse "3;" `shouldBe` Right (ExprInt 3) 48 | it "parses a float" $ do 49 | parse "3.0;" `shouldBe` Right (ExprFloat 3.0) 50 | it "parses a disjunction" $ 51 | ex "true || false" `shouldBe` "true || false" 52 | it "parses || and &&" $ 53 | ex "1 || 2 && 3" `shouldBe` "1 || 2 && 3" 54 | it "parses equality" $ do 55 | ex "1==1" `shouldBe` "1 == 1" 56 | it "parses as operand" $ do 57 | ex "1==1 && 2==2" `shouldBe` "1 == 1 && 2 == 2" 58 | it "parses grouped expr" $ 59 | ex "(1 + 2 == 4) || 5 || 6" `shouldBe` "(1 + 2 == 4) || 5 || 6" 60 | it "parses an inner group" $ 61 | ex " 1 +(2) + 3" `shouldBe` "1 + (2) + 3" 62 | it "parses a strict eq" $ 63 | ex "3===4" `shouldBe` "symbol === not allowed. 0:4" 64 | it "parses a dot access" $ 65 | ex "hello.world == 3" `shouldBe` "hello.world == 3" 66 | it "parses a function call with 1 param" $ 67 | ex "f(g(1))" `shouldBe` "f(g(1))" 68 | it "parses a function call with 2 params" $ 69 | ex "f(g(1, 2))" `shouldBe` "f(g(1, 2))" 70 | it "parses a function call with 1 expr param" $ 71 | ex "f(g(1 || 2))" `shouldBe` "f(g(1 || 2))" 72 | it "parses a function call with 1 var param" $ 73 | ex "f(g(a))" `shouldBe` "f(g(a))" 74 | it "parses a function call with 1 expr var param" $ 75 | ex "g(a || b)" `shouldBe` "g(a || b)" 76 | it "parses a nested function call with 1 expr var param" $ 77 | ex "f(g(a || a.b.c))" `shouldBe` "f(g(a || a.b.c))" 78 | it "parses a function call with 1 func param" $ 79 | ex "f(g())" `shouldBe` "f(g())" 80 | it "parses a unary op bang" $ 81 | ex "!3 || 4" `shouldBe` "!3 || 4" 82 | it "parses a unary op +" $ 83 | ex "+3 + +4" `shouldBe` "+3 + +4" 84 | it "errs on an unexpected operator ?" $ 85 | ex "3 + ?4" `shouldBe` "could not parse right side of operator + 0:3" 86 | it "fails on bad operators" $ 87 | ex "4++" `shouldBe` "symbol ++ not allowed. 0:3" 88 | it "+++ fails" $ 89 | ex "+++4" `shouldBe` "symbol ++ not allowed. 0:2" 90 | it "parses a simple index" $ 91 | ex "x[4]" `shouldBe` "x[4]" 92 | it "parses a complex index" $ 93 | ex "a.b.c(hello)[a.b || !c.d + 5]" `shouldBe` "a.b.c(hello)[a.b || !c.d + 5]" 94 | it "parses an index inside index" $ 95 | ex "x[f + x[4]]" `shouldBe` "x[f + x[4]]" 96 | it "errs on missing index bracket" $ 97 | ex "f(x[3)" `shouldBe` "index missing closing bracket `]` 0:5" 98 | it "parses index range" $ 99 | ex "x[4:7]" `shouldBe` "x[4:7]" 100 | it "parses expr index range" $ 101 | ex "x[f(a.b) : g(p( n + 7))]" `shouldBe` "x[f(a.b):g(p(n + 7))]" 102 | 103 | -- Ternary Operator 104 | it "parses a basic ternary" $ 105 | ex "a ? b : c" `shouldBe` "a ? b : c" 106 | it "parses a nested ternary" $ 107 | ex "a ? b ? c : d : e" `shouldBe` "a ? b ? c : d : e" 108 | it "parses a nested ternary" $ 109 | ex "a ? b : c ? d : e" `shouldBe` "a ? b : c ? d : e" 110 | it "parses a binary expression within a ternary" $ 111 | ex "a || aa ? b || bb : c || cc" `shouldBe` "a || aa ? b || bb : c || cc" 112 | 113 | -- PATHS 114 | it "parses a simple path" $ 115 | ex "(/docs)" `shouldBe` "(/docs)" 116 | it "parses a path with variable" $ 117 | ex "(/docs/$(abc)/123)" `shouldBe` "(/docs/$(abc)/123)" 118 | it "parses a path with dot operator" $ 119 | ex "(/hello/$(a.world)/1)" `shouldBe` "(/hello/$(a.world)/1)" 120 | 121 | -- LISTS 122 | it "parses a number list" $ 123 | ex "[ 1, 2 , 3 ]" `shouldBe` "[1, 2, 3]" 124 | it "parses a string list" $ 125 | ex "['a', 'b', 'c']" `shouldBe` "['a', 'b', 'c']" 126 | it "parses an empty list" $ 127 | ex "[]" `shouldBe` "[]" 128 | it "parses an expr list" $ 129 | ex "[a ||b, true && f() || 5]" `shouldBe` "[a || b, true && f() || 5]" 130 | 131 | -- MISC 132 | it "parses a catenation with string" $ 133 | ex "'a' + b" `shouldBe` "'a' + b" 134 | it "fails on missing list bracket" $ 135 | ex "f(f.g([ 1, 2))" `shouldBe` "list missing closing bracket `]` 0:12" 136 | it "parses a map literal" $ 137 | ex "hello.map({ \"a\": 3})" `shouldBe` "hello.map({ \"a\": 3 })" 138 | it "parses a nested map literal" $ 139 | ex "hello.map({ \"a\": { \"bac\": a.b.c()} })" `shouldBe` "hello.map({ \"a\": { \"bac\": a.b.c() } })" 140 | it "parses a nested map literal" $ 141 | ex "hello.map({ a: { bac: a.b.c()} })[123:123]" `shouldBe` "hello.map({ \"a\": { \"bac\": a.b.c() } })[123:123]" 142 | it "parses a ' string" $ 143 | ex "x('ff gg')" `shouldBe` "x('ff gg')" 144 | it "parses a \" string" $ 145 | ex "x(\"ff gg\")" `shouldBe` "x(\"ff gg\")" 146 | it "parses a \" string with semicolon" $ 147 | ex "x(\"ff;gg\")" `shouldBe` "x(\"ff;gg\")" 148 | it "parses a string equality" $ 149 | ex "\"111\" == \"222\"" `shouldBe` "\"111\" == \"222\"" 150 | it "parses a string equality" $ 151 | ex "\"111\" == \"222\"" `shouldBe` "\"111\" == \"222\"" 152 | it "parses an expression with inner semicolons" $ do 153 | ex "x==\"123;\" && y-3==4 ;" `shouldBe` "x == \"123;\" && y - 3 == 4" 154 | it "parses issue 11 func call" $ do 155 | ex "exists(/test/$(a)/$(b())/$(b(a)))" `shouldBe` "exists(/test/$(a)/$(b())/$(b(a)))" 156 | it "parses (issue 11) array within a func call" $ do 157 | ex "data().keys().hasAll(['a', 'b', 'c'])" `shouldBe` "data().keys().hasAll(['a', 'b', 'c'])" 158 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /integration/test/primitiveTypes.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The logic of allow rules in combination with type checks 3 | */ 4 | import {GeoPoint} from 'firebase/firestore' 5 | import firebaseTesting = require('@firebase/rules-unit-testing'); 6 | import {RulesTestEnvironment, RulesTestContext} from '@firebase/rules-unit-testing' 7 | import {getRules} from '../util/rules'; 8 | 9 | import {ListTest, OptListTest, MapTest, LitTest, WardTimestamp, TimestampTest, isTimestamp, GeoTest, WardGeoPoint, isGeoPoint, AnyTest, FirewardInput, FirewardOutput, TimestampNullTest, QuotedTest} from '../wards/primitiveTypes'; 10 | 11 | import { expect } from 'chai'; 12 | 13 | const WARD_NAME = 'primitiveTypes'; 14 | 15 | let app: RulesTestContext; 16 | let testEnv: RulesTestEnvironment 17 | let firestore: ReturnType 18 | 19 | const projectId = WARD_NAME.toLowerCase(); 20 | const uid = '123'; 21 | 22 | before(async function() { 23 | const rules = getRules(WARD_NAME); 24 | testEnv = await firebaseTesting.initializeTestEnvironment({projectId, firestore: {rules}}); 25 | 26 | app = testEnv.authenticatedContext(uid, {}); 27 | firestore = app.firestore(); 28 | }); 29 | 30 | describe(WARD_NAME, function() { 31 | let count = 0; 32 | beforeEach(async function() { 33 | count++; 34 | }); 35 | 36 | afterEach(async ()=>{ 37 | testEnv.clearFirestore(); 38 | }); 39 | 40 | describe(`authenticated`, function() { 41 | 42 | beforeEach(function () { 43 | 44 | }); 45 | 46 | it(`succeeds saving a list primitive field`, async function() { 47 | const a: ListTest = { test: 'abcabcabcabcabcabcabcabcabcabcabcabc'.split('') } 48 | await firebaseTesting.assertSucceeds(firestore.collection(`list`).doc(uid).set(a)); 49 | const b: ListTest = { test: 'bb'.split('') } 50 | await firebaseTesting.assertSucceeds(firestore.collection(`list`).doc(uid).set(b)); 51 | }); 52 | 53 | it(`succeeds creating an optional list primitive field`, async function() { 54 | 55 | const a: OptListTest = { } 56 | await firebaseTesting.assertSucceeds(firestore.collection(`olist`).doc(uid).set(a)); 57 | const b: OptListTest = { test: 'bb'.split('') } 58 | await firebaseTesting.assertSucceeds(firestore.collection(`olist`).doc(uid).set(b)); 59 | }); 60 | 61 | it(`succeeds uncreating an optional list primitive field`, async function() { 62 | 63 | const a: OptListTest = { test: 'bb'.split('') } 64 | await firebaseTesting.assertSucceeds(firestore.collection(`olist`).doc(uid).set(a)); 65 | const b: OptListTest = { } 66 | await firebaseTesting.assertSucceeds(firestore.collection(`olist`).doc(uid).set(b)); 67 | }); 68 | 69 | it(`succeeds saving a map primitive field`, async function() { 70 | 71 | const a: MapTest = { test: {a:1,b:'2', c: true} } 72 | await firebaseTesting.assertSucceeds(firestore.collection(`map`).doc(uid).set(a)); 73 | const b: MapTest = { test: {a:1.1,b:'2.7', c: [1,2,3]} } 74 | await firebaseTesting.assertSucceeds(firestore.collection(`map`).doc(uid).set(b)); 75 | }); 76 | 77 | it(`succeeds saving number, string and boolean into any type`, async function() { 78 | const x: AnyTest = { 79 | test: 123 80 | } 81 | await firebaseTesting.assertSucceeds(firestore.collection(`any`).doc(uid).set(x)); 82 | x.test = true; 83 | await firebaseTesting.assertSucceeds(firestore.collection(`any`).doc(uid).set(x)); 84 | x.test = "fireward is like an impenetrable wall"; 85 | await firebaseTesting.assertSucceeds(firestore.collection(`any`).doc(uid).set(x)); 86 | const xx = await firestore.collection(`any`).doc(uid).get(); 87 | if (x.test!==xx.data()?.test) throw new Error(`Any item did not properly save.`) 88 | }) 89 | 90 | describe(`Literal Types`, function() { 91 | 92 | let x: LitTest; 93 | 94 | beforeEach(()=>{ 95 | x = { 96 | 'numTest': 123, 97 | 'boolTest': false, 98 | 'mixTest': 123, 99 | 'strTest': 'you' 100 | } 101 | }); 102 | 103 | it(`succeeds saving a map of literal types`, async function() { 104 | await firebaseTesting.assertSucceeds(firestore.collection(`literal`).doc(uid).set(x)); 105 | x.numTest = 234; 106 | x.mixTest = "hello"; 107 | x.strTest = 'me'; 108 | await firebaseTesting.assertSucceeds(firestore.collection(`literal`).doc(uid).set(x)); 109 | }); 110 | 111 | it(`fails saving an unmatched number`, async function() { 112 | // @ts-ignore 113 | x.numTest = 111; 114 | await firebaseTesting.assertFails(firestore.collection(`literal`).doc(uid).set(x)); 115 | }); 116 | 117 | it(`fails saving an unmatched string`, async function() { 118 | // @ts-ignore 119 | x.strTest = 'x'; 120 | await firebaseTesting.assertFails(firestore.collection(`literal`).doc(uid).set(x)); 121 | }); 122 | 123 | it(`fails saving an unmatched boolean`, async function() { 124 | // @ts-ignore 125 | x.boolTest = true; 126 | await firebaseTesting.assertFails(firestore.collection(`literal`).doc(uid).set(x)); 127 | }); 128 | 129 | }); 130 | 131 | describe(`WardTimestamp`, function() { 132 | 133 | it(`typechecks by isTimestamp`, async function() { 134 | const x: TimestampTest = { 135 | test: new Date() 136 | }; 137 | await firebaseTesting.assertSucceeds(firestore.collection(`time`).doc(uid).set(x)); 138 | const xx = await firestore.collection('time').doc(uid).get(); 139 | const data = xx.data(); 140 | const y: unknown = data && data.test; 141 | if (!isTimestamp(y)) throw new Error(`Expected a WardTimestamp but got ` + JSON.stringify(y, null, ' ')) 142 | if (!y.nanoseconds) throw new Error(`No nanoseconds? What a strange coincidence. Re-run the test.`) 143 | }); 144 | 145 | }); 146 | 147 | describe(`WardGeoPoint`, function() { 148 | it(`typechecks a geopoint`, async function() { 149 | const x: GeoTest = { 150 | test: new GeoPoint(1, 1) 151 | }; 152 | if (!isGeoPoint(x.test)) throw new Error(`A correct GeoPoint didn't typecheck`); 153 | await firebaseTesting.assertSucceeds(firestore.collection(`geo`).doc(uid).set(x)); 154 | const xx = await firestore.collection('geo').doc(uid).get(); 155 | const data = xx.data(); 156 | const y: unknown = data && data.test; 157 | if (!isGeoPoint(y)) throw new Error(`Expected a WardGeoPoint but got ` + JSON.stringify(y, null, ' ')) 158 | if (y.latitude!=x.test.latitude) throw new Error(`Geopoint.latitude did not save correctly`) 159 | }); 160 | 161 | }); 162 | 163 | describe(`Quoted property names`, function() { 164 | it(`reads/writes Japanese, as well as non-word characters`, async function() { 165 | const x: QuotedTest = { 166 | 'ハロー・ワールド': 'hello world', 167 | 'abc': { 168 | '..-': {']': 123} 169 | } 170 | }; 171 | await firebaseTesting.assertSucceeds(firestore.collection('quoted').doc(uid).set(x)); 172 | const xx = await firestore.collection('quoted').doc(uid).get(); 173 | expect(xx.data()).eql(x); 174 | }); 175 | }); 176 | 177 | }) 178 | }) 179 | -------------------------------------------------------------------------------- /src/Parser.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -fwarn-incomplete-patterns #-} 2 | 3 | -- adapted from Monadic Parsing in Haskell 4 | -- http://www.cs.nott.ac.uk/~pszgmh/pearl.pdf 5 | -- and Thinking Functionally in Haskell 6 | 7 | 8 | module Parser 9 | ( getc 10 | , sat 11 | , satS 12 | , none 13 | , many 14 | , alt 15 | , enum 16 | , some 17 | , char 18 | , string 19 | , _const 20 | , digit 21 | , lower 22 | , upper 23 | , space 24 | , optional' 25 | , token 26 | , symbol 27 | , grouped 28 | , manywith 29 | , somewith 30 | , charIn 31 | , whileNot 32 | , apply 33 | , Parser 34 | , unparsable 35 | , guard 36 | , failWith 37 | , guardWith 38 | , failure 39 | , require 40 | , ParserResult 41 | , ParserSuccess(..) 42 | , (<|>), (>>=) 43 | , trim 44 | ) where 45 | 46 | -- import Data.Map.Strict 47 | import Data.Char ( isDigit , isLower , isUpper, isSpace) 48 | import Data.List (dropWhileEnd, lookup) 49 | 50 | import Control.Monad ((>>=), return, ap, liftM, guard) 51 | import Control.Applicative (Alternative, (<|>), empty, many, some, optional) 52 | import Prelude hiding (head) 53 | 54 | data Warning = Warning { warningText::String, warningLine::Int, warningCol::Int } 55 | type ParserError = Maybe (String, Int, Int) 56 | data ParserSuccess a = ParserSuccess 57 | { parserResult::a 58 | , unparsed::String 59 | , parserLine::Int 60 | , parserCol::Int 61 | , parserWarnings::[Warning] 62 | } -- (result, unparsed, line, col, required) 63 | type ParserResult a = Either ParserError (ParserSuccess a) 64 | 65 | newtype Parser a = Parser (String -> Either ParserError (ParserSuccess a)) 66 | 67 | 68 | maybeHead [] = Nothing 69 | maybeHead (x:_) = Just x 70 | 71 | apply :: Parser a -> String -> ParserResult a 72 | apply (Parser p) s = p s 73 | 74 | instance Functor Parser where 75 | fmap = liftM 76 | 77 | instance Applicative Parser where 78 | pure = return 79 | (<*>) = ap 80 | 81 | -- this low-level function represents a critical failure that 82 | -- should produce error output to the user. 83 | failure = Left . Just 84 | 85 | -- This represents a point at which the parser does not know what to do. 86 | -- Unlike a failure, this will not produce a parser error. This combinator 87 | -- explicitly says: this is not the structure you're looking for. 88 | -- `guard True` does a similar thing, but guard returns () but `unparsable` 89 | -- returns the generic Parser a, so you can do things like 90 | -- `if cond then return myValue else unparsable` 91 | unparsable :: Parser a 92 | unparsable = Parser $ \s -> Left Nothing 93 | 94 | -- this ends the compilation with an error message intended for the user 95 | -- `if null closingParen then failWith "Missing closing paren" else return myResult` 96 | failWith :: String -> Parser a 97 | failWith msg = Parser (\s->failure (msg, 0, 0)) 98 | 99 | guardWith :: String -> Bool -> Parser () 100 | guardWith msg True = return () 101 | guardWith msg False = failWith msg 102 | 103 | -- This represents an empty result set for parsers that look for multiple results. 104 | none :: Parser [a] 105 | none = return [] 106 | 107 | instance Alternative Parser where 108 | empty = Parser (\s -> Left Nothing) 109 | p <|> q = Parser f where 110 | f s = let pick (Left Nothing) = apply q s 111 | pick ps@(Right x) = ps 112 | pick error = error 113 | in pick $ apply p s 114 | 115 | instance Monad Parser where 116 | return x = Parser (\s -> Right $ ParserSuccess x s 0 0 []) 117 | p >>= q = Parser outer 118 | where outer s = res (apply p s) 119 | res (Right (ParserSuccess { 120 | parserResult = x, 121 | unparsed = s', 122 | parserLine = l', 123 | parserCol = c', 124 | parserWarnings = w' 125 | })) = inner (apply (q x) s') l' c' w' 126 | res (Left error) = Left error 127 | inner (Right (ParserSuccess 128 | { parserResult = y 129 | , unparsed = s'' 130 | , parserLine = l'' 131 | , parserCol = c'' 132 | , parserWarnings = w'' 133 | })) l' c' w' = Right $ ParserSuccess 134 | { parserResult = y 135 | , unparsed = s'' 136 | , parserLine = l' + l'' 137 | , parserCol = if l'' > 0 then c'' else c' + c'' 138 | , parserWarnings = w' ++ w'' 139 | } 140 | -- (y, s'', l'+l'', if l'' > 0 then c'' else c' + c'') 141 | -- (y, s'', l'', c'')) l' c' = Right (y, s'', l'+l'', if l'' > 0 then c'' else c' + c'') 142 | inner (Left (Just (error, l'', c''))) l' c' w' = failure (error, l'+l'', if l'' > 0 then c'' else c' + c'') 143 | inner (Left Nothing) l' c' w' = Left Nothing 144 | 145 | require msg p = Parser q 146 | where q s = res (apply p s) 147 | res val@(Right _) = val 148 | res (Left Nothing) = failure (msg, 0, 0) 149 | res e = e 150 | 151 | ifLineSep c t f = if c=='\n' || c=='\r' then t else f 152 | 153 | getc :: Parser Char 154 | getc = Parser f where 155 | f [] = Left Nothing 156 | -- f (c:cs) = Right (c, cs, ifLineSep c 1 0 , ifLineSep c 0 1) 157 | f (c:cs) = Right $ ParserSuccess 158 | { parserResult = c 159 | , unparsed = cs 160 | , parserLine = ifLineSep c 1 0 161 | , parserCol = ifLineSep c 0 1 162 | , parserWarnings = [] 163 | } 164 | 165 | sat :: (Char -> Bool) -> Parser Char 166 | sat p = do { c <- getc; guard (p c); return c } 167 | 168 | satS :: (Char -> Bool) -> Parser String 169 | satS p = (:[]) <$> sat p 170 | 171 | char :: Char -> Parser Char 172 | char x = sat (==x) 173 | 174 | string :: String -> Parser () 175 | string "" = return () 176 | string (c:cs) = do { char c; string cs; return () } 177 | 178 | whileNot :: Parser () -> Parser String 179 | whileNot p = many_ next 180 | where next = do 181 | z <- optional p 182 | guard (z==Nothing) 183 | getc 184 | many_ p = do 185 | v <- p 186 | (v:) <$> (many_ p <|> return []) 187 | 188 | digit = sat isDigit 189 | lower = sat isLower 190 | upper = sat isUpper 191 | 192 | head :: [a] -> Maybe a 193 | head [] = Nothing 194 | head (x:xs) = Just x 195 | 196 | optional' :: Parser [a] -> Parser [a] 197 | optional' p = p <|> none 198 | 199 | lineComment = char '/' >> char '/' >> many (sat (\c->c/='\n' && c/='\r')) >> do 200 | return () 201 | 202 | spaceSat = sat isSpace >> return () 203 | space = many (spaceSat <|> lineComment ) >> return () 204 | 205 | token :: Parser a -> Parser a 206 | token p = space >> p 207 | 208 | symbol :: String -> Parser () 209 | symbol = token . string 210 | 211 | grouped :: String -> String -> Parser a -> Parser a 212 | grouped o c p = do { symbol o; 213 | v <- p; 214 | symbol c; 215 | return v; } 216 | 217 | manywith sep p = optional' (somewith sep p) 218 | somewith sep p = do 219 | first <- p 220 | rest <- many (sep >> p) 221 | return (first:rest) 222 | 223 | charIn :: [Char] -> Parser Char 224 | charIn "" = empty 225 | charIn (c:cs) = char c <|> charIn cs 226 | 227 | _const :: String -> Parser String 228 | _const s = do 229 | string s 230 | return s 231 | 232 | 233 | alt :: (a -> Parser a) -> [a] -> Parser a 234 | alt p [] = unparsable 235 | alt p (x:xs) = p x <|> alt p xs 236 | 237 | enum :: [String] -> Parser String 238 | enum xs = alt (\s -> do { symbol s ; return s }) xs 239 | 240 | trim :: String -> String 241 | trim = dropWhile isSpace . dropWhileEnd isSpace 242 | -------------------------------------------------------------------------------- /src/ExprParser.hs: -------------------------------------------------------------------------------- 1 | module ExprParser ( 2 | expr, -- the main combinator 3 | Expr(..), 4 | BinOp(..), 5 | UnOp(..), 6 | PathPart(..), 7 | FuncCall(..) 8 | ) where 9 | 10 | {- 11 | - Reference Shortcuts 12 | - https://firebase.google.com/docs/rules/rules-language#firestore 13 | - https://firebase.google.com/docs/reference/rules/rules 14 | -} 15 | 16 | import Debug.Trace (trace) 17 | import Parser 18 | import Control.Applicative (optional, empty) 19 | import Data.Char (isSpace) 20 | import Data.List (intercalate) 21 | import Parser 22 | import Combinators 23 | 24 | data UnOp = OpNeg | OpPos | OpBang 25 | deriving (Eq) 26 | allunops = [ OpNeg , OpPos , OpBang ] 27 | instance Show UnOp where 28 | show OpNeg = "-" 29 | show OpPos = "+" 30 | show OpBang = "!" 31 | 32 | data BinOp = OpAnd 33 | | OpOr 34 | | OpPlus 35 | | OpMinus 36 | | OpMult 37 | | OpDiv 38 | | OpDot 39 | | OpIs 40 | | OpIn 41 | | OpMod 42 | | OpGt 43 | | OpNe 44 | | OpEq 45 | | OpGte 46 | | OpLt 47 | | OpLte 48 | deriving (Eq) 49 | 50 | data TernOp = TernOp 51 | 52 | allbinops = -- make sure to list compound ones first, e.g. `<=` before `<` 53 | [ OpGte 54 | , OpLte 55 | , OpNe 56 | , OpEq 57 | , OpOr 58 | , OpAnd 59 | , OpPlus 60 | , OpMinus 61 | , OpMult 62 | , OpDiv 63 | , OpDot 64 | , OpIs 65 | , OpIn 66 | , OpMod 67 | , OpGt 68 | , OpLt 69 | ] 70 | instance Show BinOp where 71 | show OpOr = "||" 72 | show OpAnd = "&&" 73 | show OpPlus = "+" 74 | show OpMinus = "-" 75 | show OpMult = "*" 76 | show OpDiv = "/" 77 | show OpDot = "." 78 | show OpIs = "is" 79 | show OpIn = "in" 80 | show OpMod = "%" 81 | show OpEq = "==" 82 | show OpNe = "!=" 83 | show OpGt = ">" 84 | show OpGte = ">=" 85 | show OpLt = "<" 86 | show OpLte = "<=" 87 | 88 | data FuncCall = FuncCall String [Expr] deriving (Show, Eq) 89 | data PathPart = PathPartLit String | PathPartExpr Expr 90 | deriving (Show, Eq) 91 | data Expr = ExprGrp Expr 92 | | ExprBin BinOp Expr Expr 93 | | ExprCall FuncCall 94 | | ExprVar String 95 | | ExprUn UnOp Expr 96 | | ExprStr String 97 | | ExprFloat Float 98 | | ExprInt Int 99 | | ExprBool Bool 100 | | ExprNull 101 | | ExprIndexed Expr Expr (Maybe Expr) 102 | | ExprPath [PathPart] 103 | | ExprList [Expr] 104 | | ExprMap [(String, Expr)] 105 | | ExprTern Expr Expr Expr 106 | deriving (Show, Eq) 107 | 108 | toInt :: Float -> Int 109 | toInt = round 110 | isInt :: Float -> Bool 111 | isInt x = x == fromInteger (round x) 112 | 113 | 114 | num = do 115 | neg <- optional $ symbol "-" 116 | optional space 117 | left <- fromIntegral <$> _natural 118 | decMaybe <- optional _dec 119 | let decStr = maybe "0" id decMaybe :: String 120 | let dec = readDef 0 decStr :: Float 121 | let right = shiftDec dec 122 | let val = (left + right) * (if neg==Nothing then 1 else -1) 123 | let ex = if decMaybe == Nothing then ExprInt (toInt val) else ExprFloat val 124 | return ex 125 | where 126 | shiftDec x = if x >= 1 then shiftDec (x/10) else x 127 | _dec = do 128 | symbol "." 129 | some digit 130 | 131 | 132 | expr :: Parser Expr 133 | expr = do 134 | e <- _expr 135 | optional $ symbol ";" 136 | return e 137 | where 138 | _expr = unit 139 | gp = do -- group 140 | symbol "(" 141 | e <- unit 142 | require "expected a closing ')'" $ symbol ")" 143 | return $ ExprGrp e 144 | 145 | rawpathlit :: Parser Expr 146 | rawpathlit = do 147 | 148 | let pathExpr = PathPartExpr <$> grouped "$(" ")" _expr 149 | let pathLit = fmap PathPartLit . some $ _alpha <|> digit <|> charIn "_-" 150 | 151 | let sep = symbol "/" 152 | 153 | 154 | token sep 155 | parts <- somewith sep (token pathLit <|> token pathExpr) 156 | guardWith "path must contain at least 1 part" (length parts > 0) 157 | 158 | return $ ExprPath parts 159 | 160 | pathlit :: Parser Expr 161 | pathlit = rawpathlit -- Do not enforce parens; let firebase complain about possible issues instead. 162 | -- pathlit = do 163 | -- symbol "(" 164 | -- p <- rawpathlit 165 | -- require "raw path is missing a closing paren `)`" $ symbol ")" 166 | -- return p 167 | 168 | baddies = 169 | altr [ symbol b >> failWith ("symbol " ++ b ++ " not allowed.") 170 | | b <- [ "#", "~", "===", "++", "--", "**" ] ] 171 | 172 | 173 | unop = do 174 | optional space 175 | optional baddies 176 | op <- altr [ symbol (show o) >> return o | o <- allunops ] 177 | e <- unit 178 | return $ ExprUn op e 179 | 180 | indexer = do 181 | symbol "[" 182 | e <- require "index missing expression after [" _expr 183 | r <- optional indexerRange 184 | require "index missing closing bracket `]`" $ symbol "]" 185 | return (e, r) 186 | 187 | indexerRange = do 188 | symbol ":" 189 | require "index range is missing the second part" _expr 190 | 191 | 192 | listlit = do 193 | symbol "[" 194 | e <- manywith (symbol ",") (token _expr) 195 | require "list missing closing bracket `]`" $ symbol "]" 196 | return $ ExprList e 197 | 198 | maplit = do 199 | symbol "{" 200 | e <- manywith (symbol ",") keyval 201 | require "list missing closing brace `}`" $ symbol "}" 202 | return $ ExprMap e 203 | where 204 | val = symbol ":" >> require "map object missing a value after `:`" _expr 205 | quote x = "\"" ++ x ++ "\"" 206 | rawkey = quote <$> some (_alphaNum <|> charIn "_") 207 | strkey = _stringD '"' 208 | key = rawkey <|> strkey 209 | keyval = (,) <$> token key <*> val 210 | 211 | 212 | funcCall = token $ do 213 | optional space 214 | namePath <- somewith (symbol ".") _varName 215 | let name = intercalate "." namePath 216 | symbol "(" 217 | params <- manywith (symbol ",") (token funcParam) 218 | require "function call expected a closing paren `)`" $ symbol ")" 219 | return $ FuncCall name params 220 | where funcParam = rawpathlit <|> _expr 221 | 222 | unit = ternOrExpr where 223 | 224 | atom = baddies 225 | <|> maplit 226 | <|> listlit 227 | <|> pathlit 228 | <|> unop 229 | <|> gp 230 | <|> num 231 | <|> string 232 | <|> bool 233 | <|> nil 234 | <|> func 235 | <|> var 236 | 237 | ternOrExpr = do 238 | cond <- binOrExpr 239 | mt <- optional $ tern cond 240 | return $ maybe cond id mt 241 | where 242 | tern cond = do 243 | token $ symbol "?" 244 | t <- ternOrExpr 245 | require "Ternary operator missing a `:`" . token $ symbol ":" 246 | f <- ternOrExpr 247 | return $ ExprTern cond t f 248 | 249 | 250 | 251 | 252 | binOrExpr = do 253 | left <- possiblyIndexed 254 | optional baddies 255 | op <- optional $ altr [ symbol (show o) >> return o | o <- allbinops ] 256 | let rightP op = require ("could not parse right side of operator " ++ show op) (token binOrExpr) 257 | let result Nothing = return left 258 | result (Just op) = ExprBin op left <$> rightP op 259 | result op 260 | 261 | possiblyIndexed = do 262 | e <- atom 263 | mix <- optional indexer 264 | let res Nothing = e 265 | res (Just (i, r)) = ExprIndexed e i r 266 | return $ res mix 267 | 268 | nil = token $ symbol "null" >> return ExprNull 269 | bool = token $ (symbol "true" >> return (ExprBool True)) 270 | <|> (symbol "false" >> return (ExprBool False)) 271 | string = ExprStr <$> _string 272 | 273 | var :: Parser Expr 274 | var = do 275 | optional space 276 | x <- somewith (symbol ".") _varName 277 | return $ ExprVar (intercalate "." x) 278 | func = token $ do 279 | f <- funcCall 280 | return $ ExprCall f 281 | 282 | 283 | 284 | 285 | 286 | -------------------------------------------------------------------------------- /src/RulePrinter.hs: -------------------------------------------------------------------------------- 1 | module RuleGeneratorX ( 2 | generate 3 | ) where 4 | 5 | -- Definitions: 6 | -- _type functions_ are functions that check to make sure that the data resource confirms to the type structure. 7 | -- 8 | -- 9 | 10 | import Parser 11 | import RuleParser 12 | import RuleLang 13 | import Error (Error(..)) 14 | import Loc (loc, Loc) 15 | import Data.List (findIndices, intercalate, stripPrefix) 16 | import Data.Char (toUpper) 17 | import Data.Maybe (maybe) 18 | -- import LogicPrinter (Expr(..), Op(..), printLogic) 19 | 20 | getOr l n f = if length l < n 21 | then Right (l!!n) 22 | else Left f 23 | 24 | xs `hasAnyOf` ys = not . null $ filter (flip elem $ ys) xs 25 | xs `hasSameElems` ys = (xs `hasOnly` ys) && length xs == length ys 26 | xs `hasOnly` ys = null $ filter (flip elem $ ys) xs 27 | 28 | capitalize "" = "" 29 | capitalize (c:cs) = (toUpper c) : cs 30 | 31 | joinLines = intercalate "\n" 32 | 33 | indent n = take n $ repeat ' ' 34 | indentBy n = (indent n ++) 35 | 36 | 37 | surround :: String -> String -> String -> String 38 | surround b e s = concat [b,s,e] 39 | printLoc l c = "line " ++ show (l+1) ++", column "++show (c+1) 40 | 41 | primitives :: [String] 42 | primitives = words "list string bool timestamp null int float map" 43 | 44 | -- the main exported function. calls the `gen` function internally. 45 | generate :: Bool -> String -> Either String String 46 | generate wrap source = q tree 47 | where 48 | finalize :: [TopLevel] -> [String] -> String 49 | finalize tops lines = joinLines $ if wrap then optVars tops ++ withBoilderplate (unlines lines) else lines 50 | optVar (TopLevelOpt name val) = name ++ " = " ++ val ++ ";" 51 | optVar _ = "" 52 | optVars tops = optVar <$> tops 53 | tree :: ParserResult [TopLevel]-- [([TopLevel], String)] 54 | tree = parseRules source 55 | q :: ParserResult [TopLevel] -> Either String String 56 | q (Right (tops, unparsed, l, c)) = 57 | if length unparsed > 0 58 | then Left ("Could not parse on\n on " ++ printLoc l c) 59 | else Right . finalize tops $ gen <$> tops 60 | q (Left (Just (error, l, c))) = Left (error ++ "\n on " ++ printLoc l c) 61 | q (Left Nothing) = Left ("Unexpected parser error.") 62 | 63 | funcBlock ind (FuncDef name params body) = concat 64 | [ indent ind 65 | , "function " 66 | , name 67 | , "(", intercalate ", " params, ") { return ", body', "; }" 68 | ] 69 | where 70 | body' = trim . unlines $ (indent (ind + 2) ++) <$> lines body 71 | 72 | 73 | withBoilderplate :: String -> [String] 74 | withBoilderplate code = 75 | [ "service cloud.firestore {" 76 | , " match /databases/{database}/documents {" 77 | , "" 78 | ] 79 | ++ indented ++ 80 | [ "" 81 | , " }" 82 | , "}" 83 | ] 84 | where 85 | r = lines code 86 | indented = indentLinesBy2 r :: [String] 87 | indentLinesBy2 = fmap (shift 2 ++) 88 | shift n = take (n*2) $ repeat ' ' 89 | 90 | typeFuncName typeName = "is____" ++ typeName 91 | typeFunc2 name refs = 92 | _function (typeFuncName name) ["data", "prev"] (_linesWith "|| " $ refTests) 93 | where 94 | refTests = testRef (FuncParam Nothing "data") (FuncParam Nothing "prev") False <$> refs 95 | refTest ref = 96 | refTest :: FuncParam -> FuncParam -> Bool -> TypeRef -> CodePrinter 97 | refTest ind curr prev _const (InlineTypeDef def) = 98 | defCheck ind (addr curr) (addr prev) def 99 | refTest ind pcurr@(FuncParam parent curr) pprev@(FuncParam prevParent prev) _const (TypeNameRef t arrSize) = 100 | 101 | data FuncParam = FuncParam (Maybe String) String 102 | -- the main recursive function to generate the type function 103 | typeFunc :: String -> [TypeRef] -> String 104 | typeFunc name refs = 105 | concat [ "function " 106 | , funcName name, "(data, prev) {\n return " 107 | , intercalate "\n || " $ refCheck 0 (FuncParam Nothing "data") (FuncParam Nothing "prev") False <$> refs 108 | , ";" 109 | , "\n}" 110 | ] 111 | where 112 | isReq (Field r _ _ _) = r 113 | key (Field _ n _ _) = n 114 | req = filter isReq 115 | addr :: FuncParam -> String 116 | addr (FuncParam Nothing n) = n 117 | addr (FuncParam (Just p) n) = p ++ "." ++ n 118 | funcName name = "is" ++ capitalize name 119 | 120 | defCheck :: Int -> String -> String -> TypeDef -> String 121 | defCheck ind parent prevParent (TypeDef fields) = --concat $ 122 | keyPresenceCheck ++ fieldChecks--(fmap ((line0 ++ " && ") ++) fieldChecks) 123 | where 124 | initial = if ind==2 then " " else " " 125 | line0 = ("\n" ++ indent (ind + 2)) 126 | line = ("\n" ++ indent (ind + 4)) 127 | requiredKeys = fmap key . req $ fields 128 | mx = length fields 129 | mn = length . req $ fields 130 | fieldChecks = intercalate (line0++"&& ") $ fmap (fieldCheck (ind + 2) parent prevParent) fields 131 | keyPresenceCheck = concat [ "" 132 | , if length requiredKeys > 0 133 | then _hasAll parent requiredKeys-- parent++".keys().hasAll(['" ++ intercalate "', '" requiredKeys ++ "'])" ++ line ++ "&& " 134 | else "" 135 | , line, "&& ",_sizeBetween parent mn mx --parent++".size() >= " ++ show mn 136 | -- , line, "&& ", _sizeLtw parent mx --parent++".size() <= " ++ show mx 137 | , line, "&& ", _hasOnly parent (fieldName <$> fields)-- parent ++ ".keys().hasOnly(['" ++ intercalate "', '" (fmap fieldName fields) ++ "'])" 138 | ] 139 | 140 | refCheck :: Int -> FuncParam -> FuncParam -> Bool -> TypeRef -> String 141 | refCheck ind curr prev _const (InlineTypeDef def) = 142 | defCheck ind (addr curr) (addr prev) def 143 | refCheck ind pcurr@(FuncParam parent curr) pprev@(FuncParam prevParent prev) _const (TypeNameRef t arrSize) = 144 | if t `elem` primitives then prim arrSize else func 145 | where 146 | listCond = _addr ++ " is list" 147 | prim :: (Maybe Int) -> String 148 | prim size = primType size 149 | -- eq = if _const then q else "" 150 | -- where q = "(" ++ p ++ "==null || !" ++ p ++ ".keys().hasAll(['" ++ curr ++ "']) || " ++ _prevAddr ++ "==null || " ++_addr++"=="++_prevAddr++")\n" ++ indent (ind + 2) ++ "&& " 151 | -- p = maybe "prev" id prevParent 152 | primType :: (Maybe Int) -> String -- primitive types 153 | primType Nothing 154 | | t=="null" = _addr ++ " == null " 155 | | t=="float" = "(" ++ _addr ++ " is float || " ++ _addr ++ " is int)" 156 | | otherwise = _addr ++ " is " ++ t 157 | primType (Just n) = listCond ++ (if n == 0 then "" else "\n" ++ indent (ind+1) ++ " && " ++ arrElemCheck n) 158 | sizeCheck i = 159 | "(" ++ _sizeLte _addr i ++ " || " ++ _addr ++ "[" ++ show (i-1) ++ "] is " ++ t ++ ")" 160 | arrElemCheck n = intercalate ("\n" ++ indent (ind+1) ++ " && ") [ sizeCheck i | i <- [1..n] ] 161 | -- func is defined like this because firestore does not allow tertiary logic (?:) or similar. 162 | func = "(" ++ _prevParent ++ "==null && " ++ funcwp "null" ++ " || " ++ funcwp _prevParent ++ ")" 163 | funcwp p = funcName t ++ "(" ++ _addr ++ ", " ++ p ++ ")" 164 | _addr = addr pcurr 165 | _prevAddr = addr pprev 166 | _prevParent = maybe "prev" id prevParent 167 | 168 | fieldCheck :: Int -> String -> String -> Field -> String 169 | fieldCheck ind parent prevParent (Field r n refs c) = 170 | if r 171 | then constCheck ++ formattedRefs 172 | else line0 ++ constCheck ++ "!" ++ parent ++ ".keys().hasAny(['" ++ n ++ "'])" ++ line ++ "|| " ++ formattedRefs ++ line0 173 | where 174 | formattedRefs = 175 | if length refs == 1 176 | then rs 177 | else "(" ++ line ++ " " ++ rs ++ line ++")" 178 | rs = intercalate (line ++"|| ") $ refCheck ind curr prev c <$> refs 179 | linei = "\n" ++ indent (ind) 180 | line0 = "\n" ++ indent (ind + 2) 181 | line = "\n" ++ indent (ind + 4) 182 | curr = FuncParam (Just parent) n 183 | prev = FuncParam (Just prevParent) n 184 | constCheck = if c then check else "" -- const type check 185 | where check = "&& (" 186 | ++ parent ++ "==null || !" 187 | ++ parent ++ ".keys().hasAll(['" ++ n ++ "']) || " 188 | ++ _prevAddr ++ "==null || " 189 | ++ _addr ++ "==" 190 | ++ _prevAddr ++ ")\n" ++ indent (ind + 2) ++ "&& " 191 | _prevAddr = addr prev 192 | _addr = addr curr 193 | 194 | 195 | 196 | gen :: TopLevel -> String 197 | gen (TopLevelOpt name val) = "" -- this will be generated after wrapping the code 198 | gen (TopLevelFunc def) = funcBlock 0 def 199 | gen (TopLevelType name refs) = typeFunc name refs 200 | gen (TopLevelPath def) = pathBlock 0 def 201 | where 202 | pathBlock ind (PathDef parts refs bodyItems) = 203 | joinLines . filter (/="") $ 204 | [ indent ind ++ "match /" ++ pathHead parts ++ " {" 205 | , pathTypeFunc ind refs 206 | , pathBody ind (augmentWithType bodyItems refs) 207 | , indent ind ++ "}" 208 | ] 209 | ifNo xs i e = if length xs == 0 then i else e 210 | shiftBy ind s = joinLines $ (indent ind++) <$> lines s 211 | -- do nothing if no refs provided 212 | -- augment each if bodyItems contains write update or create 213 | -- add a write otherwise 214 | pathTypeCond = "is" ++pathTypeFuncName ++ "(request.resource.data, resource==null ? null : resource.data)" 215 | pathTypeDir = PathBodyDir (PathDirective ["write"] pathTypeCond) 216 | augmentWithType bodyItems [] = bodyItems 217 | augmentWithType [] refs = [ pathTypeDir ] 218 | augmentWithType bodyItems refs = if hasWriteDirs bodyItems 219 | then withRefCheck <$> bodyItems 220 | else bodyItems 221 | withRefCheck item = if hasWriteOps item 222 | then insertRefCheck item 223 | else item 224 | insertRefCheck (PathBodyDir (PathDirective ops cond)) = 225 | PathBodyDir $ PathDirective ops (pathTypeCond ++ " && (" ++ cond ++ ")") 226 | insertRefCheck x = x 227 | hasWriteDirs = not . null . writeDirs 228 | writeDirs bodyItems = filter hasWriteOps bodyItems 229 | hasWriteOps (PathBodyDir (PathDirective ops cond)) = 230 | ops `hasAnyOf` ["write", "update", "create"] 231 | hasWriteOps _ = False 232 | pathTypeFunc ind refs = ifNo refs "" . shiftBy (ind + 2) $ typeFunc pathTypeFuncName refs 233 | pathTypeFuncName = "__PathType" 234 | pathHead parts = intercalate "/" $ pathPart <$> parts 235 | pathBody ind bodyItems = joinLines $ pathBodyItem ind <$> bodyItems 236 | pathBodyItem ind (PathBodyDir (PathDirective ops cond)) = 237 | 238 | concat [indent (ind + 2), "allow ", intercalate ", " ops, ": if ", cond, ";"] 239 | pathBodyItem ind (PathBodyFunc def) = 240 | funcBlock (ind + 2) def 241 | pathBodyItem ind (PathBodyPath def) = 242 | pathBlock (ind + 2) def 243 | 244 | pathPart (PathPartVar v) = concat ["{", v, "}"] 245 | pathPart (PathPartWild w) = concat ["{", w, "=**}"] 246 | pathPart (PathPartStatic s) = s 247 | 248 | -------------------------------------------------------------------------------- /src/RuleParser.hs: -------------------------------------------------------------------------------- 1 | module RuleParser 2 | ( parseRules 3 | , apply 4 | , _typeDef 5 | , _field 6 | , _path 7 | , _pathParts 8 | , _pathVar 9 | , _pathWild 10 | , _pathStatic 11 | , _pathDir 12 | , _funcDef 13 | -- , _funcBody 14 | , _string 15 | , _topLevelOptVar 16 | , _literalTypeRef 17 | , escape 18 | , FuncDef (..) 19 | , TypeDef (..) 20 | , PathDef (..) 21 | , PathPart (..) 22 | , PathDirective (..) 23 | , PathBodyItem (..) 24 | , TypeRef (..) 25 | , TopLevel (..) 26 | , Field (..) 27 | , RequestMethod (..) 28 | , ValidationExpr (..) 29 | , requestMethods 30 | , writeRequestMethods 31 | ) where 32 | 33 | import Parser 34 | import Control.Applicative (optional) 35 | import Data.Char (isSpace) 36 | import Text.Read (readMaybe) 37 | import Combinators 38 | import qualified ExprParser (expr) 39 | import qualified ExprPrinter (printExpr) 40 | 41 | data TopLevel = TopLevelType String [TypeRef] 42 | | TopLevelPath PathDef 43 | | TopLevelFunc FuncDef 44 | | TopLevelOpt String String 45 | deriving (Show, Eq) 46 | 47 | type PathOp = String 48 | type PathCondition = String 49 | data PathDirective = PathDirective [PathOp] PathCondition 50 | deriving (Show, Eq) 51 | data PathDef = PathDef [PathPart] [TypeRef] [PathBodyItem] 52 | deriving (Show, Eq) 53 | data PathPart = PathPartStatic String 54 | | PathPartVar String 55 | | PathPartWild String 56 | deriving (Show, Eq) 57 | 58 | data PathBodyItem = PathBodyDir PathDirective 59 | | PathBodyPath PathDef 60 | | PathBodyFunc FuncDef 61 | deriving (Show, Eq) 62 | 63 | data FuncDef = FuncDef 64 | { funcDefName :: String 65 | , funcDefParams :: [String] 66 | , funcDefVars :: [(String, String)] 67 | , funcDefBody :: String 68 | } deriving (Show, Eq) 69 | 70 | data RequestMethod = GET 71 | | LIST 72 | | CREATE 73 | | UPDATE 74 | | DELETE 75 | deriving (Eq) 76 | instance Show RequestMethod where 77 | show GET = "get" 78 | show LIST = "list" 79 | show CREATE = "create" 80 | show UPDATE = "update" 81 | show DELETE = "delete" 82 | 83 | requestMethods = 84 | [ ("get", GET) 85 | , ("list", LIST) 86 | , ("create", CREATE) 87 | , ("update", UPDATE) 88 | , ("delete", DELETE) 89 | ] 90 | writeRequestMethodList = [CREATE, UPDATE, DELETE] 91 | writeRequestMethods = let prim = filter (\m -> snd m `elem` writeRequestMethodList) requestMethods 92 | combined = [("write", writeRequestMethodList)] 93 | in concat [fmap (\(s,m)->(s, [m])) prim, combined] 94 | 95 | 96 | 97 | 98 | data ValidationExpr = ValidationExpr [RequestMethod] String 99 | deriving (Eq, Show) 100 | data TypeDef = TypeDef 101 | { typeDefMembers :: [Field] 102 | , typeDefValidations :: [ValidationExpr] 103 | } deriving (Show, Eq) 104 | 105 | -- TypeNameRef name-of-the-type (Maybe array-size) -- Nothing if not array or not array not finite 106 | data TypeRef = TypeNameRef String 107 | | InlineTypeDef TypeDef 108 | | LiteralTypeRef String 109 | | TupleTypeRef [(Bool, [TypeRef])] 110 | | ListTypeRef TypeRef 111 | | GroupedTypeRef [TypeRef] 112 | deriving (Show, Eq) 113 | data Field = Field 114 | { required :: Bool 115 | , fieldName :: String 116 | , typeRefs :: [TypeRef] 117 | , constant :: Bool 118 | } deriving (Show, Eq) 119 | 120 | 121 | _topLevelOptVar = do 122 | name <- token _var 123 | symbol "=" 124 | optional space 125 | val <- require "configuration option value is not provided" $ _string <|> some digit 126 | optional space 127 | optional $ symbol ";" 128 | return $ TopLevelOpt name val 129 | where _var = _concat [ some $ _alpha <|> charIn "_", many $ _alphaNum <|> charIn "_" ] "" 130 | 131 | _expr :: Parser String 132 | _expr = do 133 | e <- ExprParser.expr 134 | return $ ExprPrinter.printExpr e 135 | 136 | _funcVarDef :: Parser (String, String) 137 | _funcVarDef = do 138 | symbol "let" 139 | name <- require "Variable name is required but missing." $ 140 | token _expr 141 | require "Equal sign missing in variable definition." $ symbol "=" 142 | val <- require "Variable value is missing." $ 143 | token _expr 144 | return (name, val) 145 | 146 | _funcDef :: Parser FuncDef 147 | _funcDef = do 148 | symbol "function" 149 | name <- require "missing function name" $ token _varName 150 | params <- require ("function `"++name++"` is missing the parameter list") $ 151 | grouped "(" ")" paramList 152 | require ("function `"++name++"` is missing an opening `{`") $ 153 | symbol "{" 154 | vars <- many _funcVarDef 155 | optional $ symbol "return" 156 | body' <- optional $ token _expr 157 | let body = maybe "" id body' 158 | 159 | optional $ symbol ";" 160 | 161 | require ("function `"++name++"` is missing a closing `}`") $ 162 | symbol "}" 163 | 164 | guardWith ("function `"++name++"` is missing a body") (length body > 0) 165 | 166 | return $ FuncDef name params vars (trim body) 167 | 168 | where paramList = separated "," (token _varName) 169 | 170 | _typeDefValidationExpr :: Parser ValidationExpr 171 | _typeDefValidationExpr = do 172 | symbol "allow" 173 | optional space 174 | inputMethods <- manywith (symbol ",") $ token _varName 175 | let badMethods = (filter (\m -> Nothing == lookup m writeRequestMethods) inputMethods) 176 | guardWith ( "Invalid validation methods found: " ++ show badMethods) 177 | (length badMethods == 0) 178 | 179 | let onlyJust = reverse . foldl (\a mv->maybe a (:a) mv) [] 180 | let elemFrom d e = lookup e d 181 | let methods = concat . onlyJust . fmap (elemFrom writeRequestMethods) $ inputMethods 182 | guardWith "Validation expression must contain at least one request method (create, update, delete)" 183 | (length methods > 0) 184 | require "Validation expression missing a ':'" $ symbol ":" 185 | optional $ symbol "if" 186 | body <- require "Validation expressiom missing body" $ token _expr 187 | 188 | return $ ValidationExpr methods body 189 | 190 | 191 | 192 | _typeDef :: Parser TypeDef 193 | _typeDef = grouped "{" "}" $ do 194 | members <- manywith (optional $ symbol ",") _field 195 | optional $ symbol "," 196 | validations <- manywith (optional $ symbol ",") _typeDefValidationExpr 197 | return $ TypeDef members validations 198 | 199 | _tupleTypeRef :: Parser TypeRef 200 | _tupleTypeRef = do 201 | symbol "[" 202 | refs <- manywith (symbol ",") _ref 203 | guardWith "Tuples must contain at least one item." (length refs > 0) 204 | require "Missing a closing ']' for a tuple." $ symbol "]" 205 | return $ TupleTypeRef refs 206 | where _ref = do 207 | rs <- token _typeRefUnion 208 | q <- optional $ symbol "?" 209 | return (q == Nothing, rs) 210 | 211 | _inlineTypeDefRef :: Parser TypeRef 212 | _inlineTypeDefRef = InlineTypeDef <$> _typeDef 213 | _atomTypeRef :: Parser TypeRef 214 | _atomTypeRef = _literalTypeRef <|> _singleTypeName <|> _inlineTypeDefRef <|> _tupleTypeRef 215 | 216 | -- Extract all the type names into a flat array. 217 | -- Useful for validation. 218 | getNamed :: TypeRef -> [String] 219 | getNamed (TypeNameRef name) = [name] 220 | getNamed (ListTypeRef ref) = getNamed ref 221 | getNamed (GroupedTypeRef refs) = foldMap getNamed refs 222 | getNamed (TupleTypeRef tuple) = foldMap getNamed (foldMap snd tuple) 223 | getNamed (InlineTypeDef _) = [] 224 | getNamed (LiteralTypeRef _) = [] 225 | 226 | _groupedTypeRef :: Parser TypeRef 227 | _groupedTypeRef = do 228 | symbol "(" 229 | refs <- manywith (symbol "|") $ _anyTypeRef 230 | guardWith "Parentheses must contain at least one type." (length refs > 0) 231 | require "A grouped type is missing the closing paren ')'" 232 | $ symbol ")" 233 | return $ GroupedTypeRef refs 234 | 235 | _listTypeRef :: Parser TypeRef 236 | _listTypeRef = do 237 | ref <- _groupedTypeRef <|> _atomTypeRef 238 | symbol "[" 239 | arrSize <- optional $ token _natural 240 | guardWith "Explicit list size must be between 1 and 12" (sizeCheck arrSize) 241 | require "List is missing a closing paren." $ symbol "]" 242 | return $ res ref arrSize 243 | where 244 | res ref Nothing = ListTypeRef ref 245 | res ref (Just n) = TupleTypeRef [ (False, [ref]) | i <- [0..n-1]] 246 | sizeCheck Nothing = True 247 | sizeCheck (Just n) = n > 0 && n <= 12 248 | 249 | 250 | _anyTypeRef :: Parser TypeRef 251 | _anyTypeRef = do 252 | ref <- _listTypeRef <|> _groupedTypeRef <|> _atomTypeRef 253 | let names = getNamed ref 254 | let noNumber = all (/="number") names 255 | guardWith "type 'number' is disallowed to avoid a common source of confusion" noNumber 256 | return ref 257 | 258 | _typeRefUnion :: Parser [TypeRef] 259 | _typeRefUnion = manywith (symbol "|") $ token _anyTypeRef 260 | 261 | _singleTypeName = do 262 | name <- token _varName 263 | return $ TypeNameRef name 264 | 265 | -- _listTypeRef = do 266 | 267 | -- return . maybe (TypeNameRef name Nothing) $ \n -> 268 | -- TupleTypeRef [ (False, TypeNameRef name Nothing) ] 269 | -- return $ TypeNameRef name arrSize 270 | _literalTypeRef :: Parser TypeRef 271 | _literalTypeRef = 272 | LiteralTypeRef <$> token (tokenVal <|> numVal <|> stringVal) 273 | where 274 | tokenVal = _const "true" <|> _const "false" 275 | stringVal = _string 276 | leftAndRight [] Nothing = unparsable 277 | leftAndRight [] (Just ds) = return $ "0." ++ dig2str ds 278 | leftAndRight ls Nothing = return $ dig2str ls 279 | leftAndRight ls (Just rs) = return $ dig2str ls ++ "." ++ dig2str rs 280 | dig2str ds = concat [ [d] | d <- ds ] 281 | numVal = do 282 | neg <- optional $ symbol "-" 283 | left <- many digit 284 | right <- optional $ do 285 | symbol "." 286 | some digit 287 | leftAndRight left right 288 | 289 | 290 | _readonly :: Parser Bool 291 | _readonly = (/=Nothing) <$> (optional . token $ symbol "readonly") 292 | 293 | _fieldName :: Parser String 294 | _fieldName = _varName <|> do 295 | full <- _string 296 | 297 | let name = drop 1 . take (length full - 1) $ full -- strip the quotes 298 | guardWith ("field `"++ name ++"` contains illegal character '/'") (all (/='/') name) 299 | guardWith ("field `"++ name ++"` cannot consist entirely of periods") (any (/='.') name) 300 | guardWith ("field `"++ name ++"` cannot match __.*__") (not (length name > 3 && take 2 name == "__" && drop (length name - 2) name == "__")) 301 | return full 302 | 303 | 304 | 305 | 306 | _field :: Parser Field 307 | _field = do 308 | readonly <- _readonly 309 | name <- token _fieldName 310 | opt <- optional $ symbol "?" 311 | symbol ":" 312 | isConst <- optional $ symbol "const" 313 | types <- _typeRefUnion 314 | guardWith ("field `"++ name ++"` lacks a type" ) (length types > 0) 315 | return $ Field (opt == Nothing) name types (isConst /= Nothing || readonly) 316 | 317 | _topLevelType :: Parser TopLevel 318 | _topLevelType = do 319 | symbol "type" 320 | name <- require "type name missing" $ token _varName 321 | require "missing `=` after type name" $ symbol "=" 322 | members <- _typeRefUnion 323 | guardWith ("type `"++ name ++"` is missing definition" ) (length members > 0) 324 | optional $ symbol ";" 325 | return $ TopLevelType name members 326 | 327 | _pathStatic :: Parser PathPart 328 | _pathStatic = do 329 | start <- _alphaNum <|> charIn "_$" 330 | rest <- many $ _alphaNum <|> charIn "_$-:" 331 | return $ PathPartStatic (start:rest) 332 | 333 | _pathVar :: Parser PathPart 334 | _pathVar = do 335 | n <- grouped "{" "}" _varName 336 | return $ PathPartVar n 337 | 338 | _pathWild :: Parser PathPart 339 | _pathWild = grouped "{" "}" $ do 340 | v <- _varName 341 | string "=**" 342 | return $ PathPartWild v 343 | 344 | _pathParts :: Parser [PathPart] 345 | _pathParts = manywith (char '/') (_pathVar <|> _pathStatic <|> _pathWild) 346 | 347 | _pathType :: Parser [TypeRef] 348 | _pathType = do 349 | symbol "is" 350 | refs <- token _typeRefUnion 351 | return refs 352 | 353 | _pathDir :: Parser PathDirective 354 | _pathDir = do 355 | symbol "allow" 356 | ops <- manywith (symbol ",") $ enum 357 | [ "read" 358 | , "get" 359 | , "list" 360 | , "write" 361 | , "create" 362 | , "update" 363 | , "delete" ] 364 | guardWith "must provide at least one operation in a path directive" (length ops > 0) 365 | e <- optional explicit 366 | let dir = PathDirective ops $ maybe "true" id e 367 | optional $ symbol ";" 368 | return dir 369 | where 370 | explicit = do 371 | symbol ":" 372 | optional $ symbol "if" 373 | optional space 374 | body <- token _expr 375 | return body 376 | 377 | _pathBodyFunc = PathBodyFunc <$> _funcDef 378 | _pathBodyPath = PathBodyPath <$> _path 379 | _pathBodyDir = PathBodyDir <$> _pathDir 380 | 381 | _path :: Parser PathDef 382 | _path = do 383 | symbol "match" 384 | optional $ symbol "/" 385 | parts <- require "expected a path after `match`" $ token _pathParts 386 | className <- (_pathType <|> return []) 387 | require "expected a `{`" $ symbol "{" 388 | body <- many $ _pathBodyDir <|> _pathBodyFunc <|> _pathBodyPath 389 | require "expected a closing `}`" $ symbol "}" 390 | return $ PathDef parts className body 391 | 392 | _topLevel = _topLevelOptVar 393 | <|> (TopLevelPath <$> _path) 394 | <|> _topLevelType 395 | <|> TopLevelFunc <$> _funcDef 396 | 397 | parseRules :: String -> ParserResult [TopLevel] 398 | parseRules = apply (many _topLevel ) . trim 399 | -------------------------------------------------------------------------------- /test/RuleGeneratorSpec.hs: -------------------------------------------------------------------------------- 1 | module RuleGeneratorSpec (main, spec) where 2 | 3 | import Parser (trim) 4 | import Error 5 | import Loc 6 | import RuleParser 7 | import RuleGenerator 8 | 9 | import Data.Char (isDigit) 10 | import Control.Applicative 11 | import Control.Monad 12 | import Control.Monad.IO.Class (liftIO) 13 | import Test.Hspec 14 | import Test.QuickCheck 15 | import Debug.Trace (trace) 16 | 17 | main :: IO () 18 | main = hspec spec 19 | 20 | esc c r (a:b:s) = if [a,b]==['\\', c] 21 | then r:(esc c r s) else a:(esc c r (b:s)) 22 | esc c r s = s 23 | repN = esc 'n' '\n' 24 | repQ = esc '"' '\"' 25 | repA = repN . repQ 26 | showN :: Show a => a -> String 27 | showN = repA . show 28 | showE (Right x) = "Right " ++ repA x 29 | showE (Left x) = "Left " ++ repA x 30 | 31 | g = showE . RuleGenerator.generate False 32 | gv = showE . RuleGenerator.generate True 33 | gt z = (\x->trace (showN x) x) (g z) 34 | gu = g . trim . unlines 35 | r = ("Right " ++) . repA 36 | ru = r . trim . unlines 37 | 38 | startsWith :: String -> String -> Bool 39 | startsWith str = (==str) . take (length str) 40 | 41 | spec :: Spec 42 | spec = do 43 | describe "Rule Generator" $ do 44 | it "generates a simple path" $ 45 | g "match /x {}" `shouldBe` r "match /x {\n \n}" 46 | it "generates a simple function" $ 47 | g "function f(a,b,c) { return 123; }" 48 | `shouldBe` 49 | r "function f(a, b, c) {\n return 123;\n}" 50 | it "generates a path in path" $ 51 | gu 52 | [ "match /a/{b} {" 53 | , " match /x/y {" 54 | , " match /x/y {" 55 | , " }" 56 | , " }" 57 | , "}" 58 | ] `shouldBe` ru 59 | [ "match /a/{b} {" 60 | , " match /x/y {" 61 | , " match /x/y {" 62 | , " " 63 | , " }" 64 | , " }" 65 | , "}" 66 | ] 67 | 68 | it "generates a type for all optional fields" $ 69 | gu [ "type Z = {" 70 | , " a?: string" 71 | , "}" 72 | ] `shouldBe` ru 73 | [ "function is____Z(data, prev) {" 74 | , " return data.keys().hasOnly(['a'])" 75 | , " && (" 76 | , " !data.keys().hasAny(['a'])" 77 | , " || data['a'] is string" 78 | , " );" 79 | , "}" 80 | ] 81 | it "generates a type for all optional union fields" $ 82 | gu [ "type Z = {" 83 | , " a?: int|float" 84 | , "}" 85 | ] `shouldBe` ru 86 | [ "function is____Z(data, prev) {" 87 | , " return data.keys().hasOnly(['a'])" 88 | , " && (" 89 | , " !data.keys().hasAny(['a'])" 90 | , " || (" 91 | , " data['a'] is int" 92 | , " || (data['a'] is float || data['a'] is int)" 93 | , " )" 94 | , " );" 95 | , "}" 96 | ] 97 | it "generates a readonly type" $ 98 | gu [ "type Z = {" 99 | , " readonly a: string" 100 | , "}" 101 | ] `shouldBe` ru 102 | [ "function is____Z(data, prev) {" 103 | , " return data.keys().hasAll(['a'])" 104 | , " && data.keys().hasOnly(['a'])" 105 | , " && (!(prev!=null && 'a' in prev) || data['a']==prev['a'] || prev['a'] is map && data['a'] is map && data['a'].diff(prev['a']).changedKeys().size() == 0)" 106 | , " && data['a'] is string;" 107 | , "}" 108 | ] 109 | it "generates a optional const type" $ do 110 | gu [ "type Z = {" 111 | , " a?: const string" 112 | , "}" 113 | ] `shouldBe` ru 114 | [ "function is____Z(data, prev) {" 115 | , " return data.keys().hasOnly(['a'])" 116 | , " && (!(prev!=null && 'a' in prev) || data['a']==prev['a'] || prev['a'] is map && data['a'] is map && data['a'].diff(prev['a']).changedKeys().size() == 0)" 117 | , " && (" 118 | , " !data.keys().hasAny(['a'])" 119 | , " || data['a'] is string" 120 | , " );" 121 | , "}" 122 | ] 123 | describe "path functions" $ 124 | it "generates a function in a path" $ 125 | gu [ "match a {" 126 | , " function x(x) {" 127 | , " return 123;" 128 | , " }" 129 | , "}" 130 | ] `shouldBe` ru 131 | [ "match /a {" 132 | , " function x(x) {" 133 | , " return 123;" 134 | , " }" 135 | , "}" 136 | ]; 137 | it "generates a function with variables" $ 138 | gu [ "match a {" 139 | , " function x(x) {" 140 | , " let z= 123+ false;" 141 | , " return z;" 142 | , " }" 143 | , "}" 144 | ] `shouldBe` ru 145 | [ "match /a {" 146 | , " function x(x) {" 147 | , " let z = 123 + false;" 148 | , " return z;" 149 | , " }" 150 | , "}" 151 | ] 152 | 153 | it "generates a function from a type" $ 154 | g "type X = Z | ZZ | {a:A, b?:B|BB, c:{ca:int, cb?:string}}" 155 | `shouldBe` ru 156 | [ "function is____X(data, prev) {" 157 | , " return is____Z(data, prev!=null ? prev : null)" 158 | , " || is____ZZ(data, prev!=null ? prev : null)" 159 | , " || data.keys().hasAll(['a', 'c'])" 160 | , " && data.keys().hasOnly(['a', 'b', 'c'])" 161 | , " && is____A(data['a'], prev!=null && 'a' in prev ? prev['a'] : null)" 162 | , " && (" 163 | , " !data.keys().hasAny(['b'])" 164 | , " || (" 165 | , " is____B(data['b'], prev!=null && 'b' in prev ? prev['b'] : null)" 166 | , " || is____BB(data['b'], prev!=null && 'b' in prev ? prev['b'] : null)" 167 | , " )" 168 | , " )" 169 | , " && data['c'].keys().hasAll(['ca'])" 170 | , " && data['c'].keys().hasOnly(['ca', 'cb'])" 171 | , " && data['c']['ca'] is int" 172 | , " && (" 173 | , " !data['c'].keys().hasAny(['cb'])" 174 | , " || data['c']['cb'] is string" 175 | , " );" 176 | , "}" 177 | ] 178 | 179 | it "generates a function with a readonly nested type" $ 180 | g "type X = {a:A, readonly c:{ca:int}}" 181 | `shouldBe` ru 182 | [ "function is____X(data, prev) {" 183 | , " return data.keys().hasAll(['a', 'c'])" 184 | , " && data.keys().hasOnly(['a', 'c'])" 185 | , " && is____A(data['a'], prev!=null && 'a' in prev ? prev['a'] : null)" 186 | , " && (!(prev!=null && 'c' in prev) || data['c']==prev['c'] || prev['c'] is map && data['c'] is map && data['c'].diff(prev['c']).changedKeys().size() == 0)" 187 | , " && data['c'].keys().hasAll(['ca'])" 188 | , " && data['c'].keys().hasOnly(['ca'])" 189 | , " && data['c']['ca'] is int;" 190 | , "}" 191 | ] 192 | 193 | it "generates a function with a readonly nested type and allow conditions" $ 194 | g "type X = {a:A, readonly c:{ca:int, allow create: true} allow write: true}" 195 | `shouldBe` ru 196 | [ "function is____X(data, prev) {" 197 | , " return (" 198 | , " ( request.method != 'create' || ( true ) )\n && ( request.method != 'update' || ( true ) )\n && ( request.method != 'delete' || ( true ) )" 199 | , " ) && data.keys().hasAll(['a', 'c'])" 200 | , " && data.keys().hasOnly(['a', 'c'])" 201 | , " && is____A(data['a'], prev!=null && 'a' in prev ? prev['a'] : null)" 202 | , " && (!(prev!=null && 'c' in prev) || data['c']==prev['c'] || prev['c'] is map && data['c'] is map && data['c'].diff(prev['c']).changedKeys().size() == 0)" 203 | , " && (" 204 | , " ( request.method != 'create' || ( true ) )" 205 | , " ) && data['c'].keys().hasAll(['ca'])" 206 | , " && data['c'].keys().hasOnly(['ca'])" 207 | , " && data['c']['ca'] is int;" 208 | , "}" 209 | ] 210 | it "generates a custom type tuple" $ 211 | g "type X = {y: [Y, Y?]}" `shouldBe` ru 212 | [ "function is____X(data, prev) {" 213 | , " return data.keys().hasAll(['y'])" 214 | , " && data.keys().hasOnly(['y'])" 215 | , " && ( data['y'] is list && data['y'].size() <= 2 && data['y'].size() >= 1" 216 | , " && (" 217 | , " data!=null && 'y' in data && data['y'] is list && data['y'].size() > 0 && is____Y(data['y'][0], prev!=null && 'y' in prev && prev['y'] is list && prev['y'].size() > 0 ? prev['y'][0] : null)" 218 | , " )" 219 | , " && (" 220 | , " !(data!=null && 'y' in data && data['y'] is list && data['y'].size() > 1) || data['y'][1] == null || is____Y(data['y'][1], prev!=null && 'y' in prev && prev['y'] is list && prev['y'].size() > 1 ? prev['y'][1] : null)" 221 | , " )" 222 | , " );" 223 | , "}" 224 | ] 225 | 226 | it "generates a n-tuple check of definite size" $ 227 | g "type X = { x: string[2] }" 228 | `shouldBe` ru 229 | [ "function is____X(data, prev) {" 230 | , " return data.keys().hasAll(['x'])" 231 | , " && data.keys().hasOnly(['x'])" 232 | , " && ( data['x'] is list && data['x'].size() <= 2 && data['x'].size() >= 0" 233 | , " && (" 234 | , " !(data!=null && 'x' in data && data['x'] is list && data['x'].size() > 0) || data['x'][0] == null || data['x'][0] is string" 235 | , " )" 236 | , " && (" 237 | , " !(data!=null && 'x' in data && data['x'] is list && data['x'].size() > 1) || data['x'][1] == null || data['x'][1] is string" 238 | , " )" 239 | , " );" 240 | , "}" 241 | ] 242 | 243 | 244 | 245 | 246 | it "creates a correct check for empty type object" $ 247 | g "type X = {}" `shouldBe` ru 248 | [ "function is____X(data, prev) {" 249 | , " return data.keys().hasOnly([])" 250 | , " ;" 251 | , "}" 252 | ] 253 | 254 | 255 | it "generates a path with a type" $ 256 | g "match x is X {}" `shouldBe` ru 257 | [ "match /x {" 258 | , " function is______PathType(data, prev) {" 259 | , " return is____X(data, prev!=null ? prev : null);" 260 | , " }" 261 | , " allow write: if is______PathType(request.resource.data, resource==null ? null : resource.data);" 262 | , "}" 263 | ] 264 | 265 | it "generates a path with a type and custom write condition" $ 266 | g "match x is X { allow create: if true; }" `shouldBe` ru 267 | [ "match /x {" 268 | , " function is______PathType(data, prev) {" 269 | , " return is____X(data, prev!=null ? prev : null);" 270 | , " }" 271 | , " allow create: if is______PathType(request.resource.data, resource==null ? null : resource.data) && (true);" 272 | , "}\n" 273 | ] 274 | 275 | it "generates validations for types" $ 276 | gu 277 | [ "type X = {" 278 | , "a: string" 279 | , "allow create, delete: if prev['a'] == data['a'] || 1==1" 280 | , "allow update, delete: 2==2 && 3==3" 281 | , "allow write: true" 282 | , "}" 283 | ] `shouldBe` ru 284 | [ "function is____X(data, prev) {" 285 | , " return (" 286 | , " ( request.method != 'create' || ( prev['a'] == data['a'] || 1 == 1 ) )" 287 | , " && ( request.method != 'delete' || ( prev['a'] == data['a'] || 1 == 1 ) )" 288 | , " && ( request.method != 'update' || ( 2 == 2 && 3 == 3 ) )" 289 | , " && ( request.method != 'delete' || ( 2 == 2 && 3 == 3 ) )" 290 | , " && ( request.method != 'create' || ( true ) )" 291 | , " && ( request.method != 'update' || ( true ) )" 292 | , " && ( request.method != 'delete' || ( true ) )" 293 | , " ) && data.keys().hasAll(['a'])" 294 | , " && data.keys().hasOnly(['a'])" 295 | , " && data['a'] is string;" 296 | , "}" 297 | ] 298 | 299 | it "errors on empty list of request methods in validation" $ 300 | gu 301 | [ "type X = {" 302 | , "a: string," 303 | , "allow create: true" 304 | , "allow: true" 305 | , "}" 306 | ] `shouldBe` "Left Validation expression must contain at least one request method (create, update, delete)\n on line 4, column 6" 307 | 308 | 309 | 310 | 311 | it "indents a complex file" $ do 312 | ward <- readFile "test/fixtures/indent.ward" 313 | _rule <- readFile "test/fixtures/indent.rules" 314 | let rule = take ((length _rule) - 1) _rule 315 | res <- return (g ward) 316 | g ward `shouldBe` r rule 317 | 318 | 319 | describe "Top Level Variables" $ do 320 | it "generates rule version 2 if not specified" $ 321 | gv "type X = any" `shouldBe` (trim . unlines) [ 322 | "Right rules_version = '2';", 323 | "service cloud.firestore {", 324 | " match /databases/{database}/documents {", 325 | " function is____X(data, prev) {\n return true;\n }", 326 | " }", 327 | "}" 328 | ] 329 | 330 | describe "Literal" $ do 331 | it "handle a boolean" $ 332 | g "type X = { a: true }" `shouldBe` "Right function is____X(data, prev) {\n return data.keys().hasAll(['a'])\n && data.keys().hasOnly(['a'])\n && data['a'] == true;\n}" 333 | it "handle a string" $ do 334 | g "type X = { a: 'me' | \"you\" }" `shouldBe` "Right function is____X(data, prev) {\n return data.keys().hasAll(['a'])\n && data.keys().hasOnly(['a'])\n && (\n data['a'] == 'me'\n || data['a'] == \"you\"\n );\n}" 335 | it "handle a float" $ 336 | g "type X = { a: 123.3 }" `shouldBe` "Right function is____X(data, prev) {\n return data.keys().hasAll(['a'])\n && data.keys().hasOnly(['a'])\n && data['a'] == 123.3;\n}" 337 | 338 | describe "Primitive types" $ do 339 | it "handles any nested in object" $ 340 | g "type X = { a: any }" `shouldBe` "Right function is____X(data, prev) {\n return data.keys().hasAll(['a'])\n && data.keys().hasOnly(['a'])\n && true;\n}" 341 | it "handles any as top-level type" $ 342 | g "type X = any" `shouldBe` "Right function is____X(data, prev) {\n return true;\n}" 343 | 344 | 345 | describe "Smoke Test" $ do 346 | it "passes" $ do 347 | ward <- readFile "examples/smoke-test.ward" 348 | g ward `shouldSatisfy` (not . startsWith "Left") 349 | -------------------------------------------------------------------------------- /src/RuleGenerator.hs: -------------------------------------------------------------------------------- 1 | module RuleGenerator ( 2 | generate 3 | ) where 4 | 5 | -- Definitions: 6 | -- _type functions_ are functions that check to make sure that the data resource confirms to the type structure. 7 | -- _type refs_ are references to types. E.g. in `{name: FullName}` FullName is a reference to a type. 8 | -- _type defs_ are definitions of types. E.g. the whole `{name: FullName}` is a definition. 9 | -- _inline type defs_ are defined inline. E.g. in `{name: {first: string, last: string}}` {first: string, last: string} is inline. 10 | 11 | import Parser 12 | import RuleParser 13 | import RuleLang 14 | import Error (Error(..)) 15 | import Loc (loc, Loc) 16 | import Data.List (findIndices, intercalate, stripPrefix) 17 | import Data.Char (toUpper) 18 | import Data.Maybe (maybe) 19 | import CodePrinter 20 | 21 | getOr l n f = if length l < n 22 | then Right (l!!n) 23 | else Left f 24 | 25 | xs `hasAnyOf` ys = not . null $ filter (flip elem $ ys) xs 26 | xs `hasSameElems` ys = (xs `hasOnly` ys) && length xs == length ys 27 | xs `hasOnly` ys = null $ filter (flip elem $ ys) xs 28 | 29 | capitalize "" = "" 30 | capitalize (c:cs) = (toUpper c) : cs 31 | 32 | joinLines = intercalate "\n" 33 | 34 | indent n = take n $ repeat ' ' 35 | indentBy n = (indent n ++) 36 | 37 | 38 | surround :: String -> String -> String -> String 39 | surround b e s = concat [b,s,e] 40 | printLoc l c = "line " ++ show (l+1) ++", column "++show (c+1) 41 | 42 | primitives :: [String] 43 | primitives = words "list string bool timestamp null int float map any latlng" 44 | 45 | -- the main exported function. calls the `gen` function internally. 46 | generate :: Bool -> String -> Either String String 47 | generate wrap source = q tree 48 | where 49 | finalize :: [TopLevel] -> CodePrinter -> String 50 | finalize tops lines = printCode 0 $ do 51 | if wrap then do 52 | optVars $ withRequired (isOptVar `filter` tops) 53 | _return 54 | _print "service cloud.firestore {" 55 | _indent 56 | _return 57 | _print "match /databases/{database}/documents {" 58 | _indent 59 | _return 60 | lines 61 | _deindent 62 | _return 63 | _print "}" 64 | _deindent 65 | _return 66 | _print "}" 67 | else lines 68 | 69 | withRequired tops = (TopLevelOpt "rules_version" "'2'"):tops 70 | isOptVar (TopLevelOpt name _) = name /= "rules_version" 71 | isOptVar _ = False 72 | optVar (TopLevelOpt name val) = _print $ name ++ " = " ++ val ++ ";" 73 | optVar _ = _print "" 74 | optVars tops = _lines . fmap optVar $ tops 75 | tree :: ParserResult [TopLevel] 76 | tree = parseRules source 77 | q :: ParserResult [TopLevel] -> Either String String 78 | -- q (Right (tops, unparsed, l, c)) = 79 | q (Right (ParserSuccess 80 | { parserResult = tops 81 | , unparsed = unparsed 82 | , parserLine = l 83 | , parserCol = c 84 | , parserWarnings = w 85 | })) = 86 | if length unparsed > 0 87 | then Left ("Could not parse on\n on " ++ printLoc l c) 88 | else Right . finalize tops $ genTops tops 89 | q (Left (Just (error, l, c))) = Left (error ++ "\n on " ++ printLoc l c) 90 | q (Left Nothing) = Left ("Unexpected parser error.") 91 | genTops tops = _lines $ gen <$> tops 92 | 93 | 94 | funcBlock (FuncDef name params vars body) = _function name params vars (_print body) 95 | 96 | typeFuncName typeName = "is____" ++ capitalize typeName 97 | 98 | -- quote :: [Char] -> [Char] 99 | -- quoteProp name = let q = take 1 name 100 | -- in if q == "'" || q == "\"" then name else "\"" ++ name ++ "\"" 101 | 102 | data NodeLoc = NodeIndex NodeLoc Int | NodeProp (Maybe NodeLoc) String 103 | addr :: NodeLoc -> String 104 | addr (NodeIndex root i) = addr root ++ "[" ++ show i ++ "]" 105 | addr (NodeProp Nothing prop) = prop 106 | addr (NodeProp (Just parent) prop) = addr parent ++ "[" ++ _enquote prop ++ "]" 107 | 108 | exsts :: NodeLoc -> String 109 | exsts (NodeIndex par i) = exsts par ++ " && " ++ addr par ++ " is list && " ++ addr par ++ ".size() > " ++ show i 110 | exsts (NodeProp Nothing prop) = prop ++ "!=null" 111 | exsts (NodeProp (Just parent) prop) = exsts parent ++ " && '" ++ prop ++ "' in " ++ addr parent 112 | 113 | 114 | -- the main recursive function to generate the type function 115 | typeFunc :: String -> [TypeRef] -> CodePrinter 116 | typeFunc name refs = 117 | let refCheckList = refCheck (NodeProp Nothing "data") (NodeProp Nothing "prev") False <$> refs 118 | in _function (typeFuncName name) ["data", "prev"] [] (_linesWith _or refCheckList) 119 | where 120 | isReq (Field r _ _ _) = r 121 | key (Field _ n _ _) = n 122 | onlyRequired = filter isReq 123 | 124 | nodeParent (NodeIndex p _) = Just p 125 | nodeParent (NodeProp p _) = p 126 | 127 | 128 | defCheck :: NodeLoc -> NodeLoc -> TypeDef -> CodePrinter 129 | defCheck parent prevParent (TypeDef fields validations) = do 130 | _printIf (length validations > 0) $ do 131 | _print "(" 132 | _indent 133 | _return 134 | _linesWith _and (fmap validation validations) 135 | _deindent 136 | _return 137 | _print ") " 138 | _and 139 | keyPresenceCheck 140 | _return 141 | _printIf (length fields > 0) $ do 142 | _and 143 | _linesWith _and fieldChecks 144 | where 145 | validation (ValidationExpr methods body) = 146 | _linesWith _and (fmap (validationItem body) methods) 147 | validationItem body m = do 148 | _print "( " 149 | _print ("request.method != '" ++ show m ++ "' || ") 150 | _print "( " 151 | _print body 152 | _print " )" 153 | _print " )" 154 | 155 | requiredKeys = fmap key . onlyRequired $ fields 156 | mx = length fields 157 | mn = length . onlyRequired $ fields 158 | fieldChecks = fieldCheck parent prevParent <$> fields 159 | keyPresenceCheck = do 160 | _linesWith _and . fmap _print . filter (\s->s/="") $ 161 | [ if length requiredKeys > 0 162 | then _hasAll (addr parent) requiredKeys 163 | else "" 164 | -- , _sizeBetween (addr parent) mn mx -- this section is no longer needed because of the hasAll and hasOnly checks 165 | , _hasOnly (addr parent) (fieldName <$> fields) 166 | ] 167 | 168 | 169 | refCheck :: NodeLoc -> NodeLoc -> Bool -> TypeRef -> CodePrinter 170 | refCheck curr prev _const (LiteralTypeRef val) = 171 | _print $ (addr curr) ++ " == " ++ val 172 | refCheck curr prev _const (InlineTypeDef def) = 173 | defCheck curr prev def 174 | refCheck curr prev _const (ListTypeRef ref) = 175 | _print $ addr curr ++ " is list" 176 | refCheck curr prev _const (GroupedTypeRef refs) = do 177 | _print "(" 178 | _indent; _return 179 | multiRefCheck curr prev refs 180 | _deindent; _return 181 | refCheck curr prev c (TupleTypeRef fs) = do 182 | _print "( " 183 | _print $ _addr ++ " is list " 184 | _and 185 | _print $ _sizeLte _addr maxSize 186 | _print " " 187 | _and 188 | _print $ _sizeGte _addr minSize 189 | _indent 190 | _return 191 | _and 192 | _linesWith _and [ _refLine r | r <- refs ] 193 | _deindent 194 | _return 195 | _print ")" 196 | where 197 | maxSize = length fs 198 | minSize = length . dropWhile ((==False) . fst) . reverse $ fs 199 | refs :: [(Bool, [TypeRef], Int)] 200 | refs = [ (req, ref, i) | i <- [0..length fs - 1], let (req, ref) = fs !! i ] 201 | _iaddr :: Int -> String 202 | _iaddr i = _addr ++ "[" ++ show i ++ "]" 203 | _addr = addr curr 204 | _prevAddr = addr prev 205 | _iloc i = NodeIndex curr i 206 | _refLine (req, refs, i) = do 207 | _print "(" 208 | _indent 209 | _return 210 | _printIf req $ do 211 | _print $ exsts (_iloc i) 212 | _print " " 213 | _and 214 | _printIf (not req) $ do 215 | _print $ "!(" ++ exsts (_iloc i) ++ ") " 216 | _or 217 | _print " " 218 | _print $ _iaddr i ++ " == null " 219 | _print " " 220 | _or 221 | 222 | multiRefCheck (_iloc i) (NodeIndex (prev) i) refs 223 | _deindent 224 | _return 225 | _print ")" 226 | 227 | 228 | refCheck curr prev _const (TypeNameRef t) = 229 | if t `elem` primitives then primType else func 230 | where 231 | primType :: CodePrinter -- primitive types 232 | primType 233 | | t=="any" = _print "true" 234 | | t=="null" = _print $ _addr ++ " == null " 235 | | t=="float" = _print $ "(" ++ _addr ++ " is float || " ++ _addr ++ " is int)" 236 | | otherwise = _print $ _addr ++ " is " ++ t 237 | 238 | func = _print $ funcwp exstsTern 239 | 240 | exstsTern = exsts prev ++ " ? " ++ _prevAddr ++ " : null" 241 | funcwp parent' = typeFuncName t ++ "(" ++ _addr ++ ", " ++ parent' ++ ")" 242 | _addr = addr curr 243 | _prevAddr = addr prev 244 | _prevParent = maybe "prev" id prevParent 245 | prevParent = addr <$> nodeParent prev 246 | 247 | 248 | 249 | multiRefCheck :: NodeLoc -> NodeLoc -> [TypeRef] -> CodePrinter 250 | multiRefCheck curr prev refs = do 251 | if length refs == 1 252 | then refLines 253 | else do 254 | _print "(" 255 | _indent 256 | _return 257 | refLines 258 | _deindent 259 | _line $ _print ")" 260 | where refLines = _linesWith _or (refCheck curr prev False <$> refs) 261 | 262 | 263 | fieldCheck :: NodeLoc -> NodeLoc -> Field -> CodePrinter 264 | fieldCheck parent prevParent (Field r n refs c) = do 265 | _printIf c $ do -- c means field is marked as const 266 | constCheck 267 | _return 268 | _and 269 | _printIf r $ do 270 | formattedRefs 271 | _printIf (not r) $ do 272 | _print "(" 273 | _indent 274 | _return 275 | _print $ notDefined n 276 | _return 277 | _or 278 | formattedRefs 279 | _deindent 280 | _return 281 | _print ")" 282 | where 283 | formattedRefs = 284 | if length refs == 1 285 | then rs 286 | else do 287 | _print "(" 288 | _indent 289 | _return 290 | rs 291 | _deindent 292 | _line $ _print ")" 293 | 294 | notDefined key = "!" ++ _hasAny (addr parent) [key] 295 | rs = _linesWith _or (refCheck curr prev c <$> refs) 296 | curr = NodeProp (Just parent) n 297 | prev = NodeProp (Just prevParent) n 298 | _prevAddr = addr prev 299 | _addr = addr curr 300 | constCheck = do -- const type check 301 | _print "(" 302 | _print $ _not (exsts prev) ++ " " --addr prevParent ++ "==null " 303 | _or 304 | -- _print $ "!" ++ _hasAll (addr prevParent) [n] ++ " " 305 | -- _or 306 | -- _print $ _prevAddr ++ "==null " 307 | -- _or 308 | _print $ _addr ++ "==" ++ _prevAddr ++ " " 309 | _or 310 | _print $ mapDiff _prevAddr _addr ++ ".changedKeys().size() == 0" 311 | _print ")" 312 | 313 | mapDiff prev next = prev ++ " is map && " ++ next ++ " is map && " 314 | ++ next ++ ".diff(" ++ prev ++ ")" 315 | 316 | 317 | gen :: TopLevel -> CodePrinter 318 | gen (TopLevelOpt name val) = _print "" -- this will be generated after wrapping the code 319 | gen (TopLevelFunc def) = funcBlock def 320 | gen (TopLevelType name refs) = typeFunc name refs 321 | gen (TopLevelPath def) = pathBlock def 322 | where 323 | pathBlock (PathDef parts refs bodyItems) = do 324 | _print "match /" 325 | _print $ pathHead parts 326 | _print " {" 327 | _indent 328 | _return 329 | _printIf (length refs > 0) $ do 330 | pathTypeFunc refs 331 | _return 332 | pathBody (augmentWithType bodyItems refs) 333 | _deindent 334 | _return 335 | _print "}" 336 | 337 | ifNo xs i e = if length xs == 0 then i else e 338 | pathTypeCond = typeFuncName pathTypeName ++ "(request.resource.data, resource==null ? null : resource.data)" 339 | pathTypeDir = PathBodyDir (PathDirective ["write"] pathTypeCond) 340 | -- 341 | -- do nothing if no refs provided 342 | -- augment each if bodyItems contains write update or create 343 | -- add a write otherwise 344 | -- 345 | augmentWithType :: [PathBodyItem] -> [TypeRef] -> [PathBodyItem] 346 | augmentWithType bodyItems [] = bodyItems 347 | augmentWithType [] refs = [ pathTypeDir ] 348 | augmentWithType bodyItems refs = if hasWriteDirs bodyItems 349 | then withRefCheck <$> bodyItems 350 | else bodyItems 351 | 352 | withRefCheck :: PathBodyItem -> PathBodyItem 353 | withRefCheck item = if hasWriteOps item 354 | then insertRefCheck item 355 | else item 356 | 357 | insertRefCheck :: PathBodyItem -> PathBodyItem 358 | insertRefCheck (PathBodyDir (PathDirective ops cond)) = 359 | PathBodyDir $ PathDirective ops (pathTypeCond ++ " && (" ++ cond ++ ")") 360 | 361 | insertRefCheck x = x 362 | 363 | hasWriteDirs = not . null . writeDirs 364 | writeDirs bodyItems = filter hasWriteOps bodyItems 365 | hasWriteOps :: PathBodyItem -> Bool 366 | hasWriteOps (PathBodyDir (PathDirective ops cond)) = 367 | ops `hasAnyOf` ["write", "update", "create"] 368 | hasWriteOps _ = False 369 | 370 | pathTypeFunc :: [TypeRef] -> CodePrinter 371 | pathTypeFunc refs = --ifNo refs "" . shiftBy (ind + 2) $ typeFunc pathTypeFuncName refs 372 | _printIf (length refs > 0) $ typeFunc pathTypeName refs 373 | 374 | pathTypeName = "__PathType" 375 | 376 | pathHead parts = intercalate "/" $ pathPart <$> parts 377 | 378 | pathBody :: [PathBodyItem] -> CodePrinter 379 | pathBody bodyItems = do 380 | _lines $ pathBodyItem <$> bodyItems 381 | 382 | pathBodyItem :: PathBodyItem -> CodePrinter 383 | pathBodyItem (PathBodyDir (PathDirective ops cond)) = 384 | _print $ concat ["allow ", intercalate ", " ops, ": if ", cond, ";"] 385 | pathBodyItem (PathBodyFunc def) = funcBlock def 386 | pathBodyItem (PathBodyPath def) = pathBlock def 387 | 388 | pathPart :: PathPart -> String 389 | pathPart (PathPartVar v) = concat ["{", v, "}"] 390 | pathPart (PathPartWild w) = concat ["{", w, "=**}"] 391 | pathPart (PathPartStatic s) = s 392 | -------------------------------------------------------------------------------- /test/RuleParserSpec.hs: -------------------------------------------------------------------------------- 1 | module RuleParserSpec (main, spec) where 2 | 3 | import Parser 4 | import RuleParser 5 | 6 | import Data.Char (isDigit) 7 | import Control.Applicative 8 | import Control.Monad 9 | import Test.Hspec 10 | import Test.QuickCheck 11 | import Debug.Trace (trace) 12 | 13 | 14 | _parse s = res (parseRules s) 15 | where 16 | res (Right (ParserSuccess 17 | { parserResult = x 18 | , unparsed = v 19 | , parserLine = _ 20 | , parserCol = _ 21 | , parserWarnings = w 22 | })) = Right (x, v) 23 | res (Left x) = Left x 24 | 25 | _apply p s = res (apply p s) 26 | where 27 | res (Right (ParserSuccess 28 | { parserResult = x 29 | , unparsed = u 30 | , parserLine = _ 31 | , parserCol = _ 32 | , parserWarnings = w 33 | })) = Right (x, u) 34 | res (Left x) = Left x 35 | 36 | main :: IO () 37 | main = hspec spec 38 | 39 | spec :: Spec 40 | spec = do 41 | describe "rule parser" $ do 42 | it "parses a field" $ 43 | _apply _field "zoo: null" `shouldBe` Right (Field True "zoo" [TypeNameRef "null"] False,"") 44 | it "parses a type" $ 45 | _apply _typeDef "{ zoo: null }" `shouldBe` Right (TypeDef [Field True "zoo" [TypeNameRef "null"] False] [],"") 46 | it "parses a simple type" $ 47 | _parse "type Hor = { zoo: Null }" `shouldBe` Right ([ 48 | TopLevelType "Hor" [InlineTypeDef (TypeDef [Field True "zoo" [TypeNameRef "Null"] False] [])] 49 | ],"") 50 | it "parses two simple types" $ 51 | _parse (unlines [ "type A = {a:Null}" 52 | , "type B = {b:Null}" 53 | ]) `shouldBe` Right ([ 54 | TopLevelType "A" [InlineTypeDef (TypeDef [Field True "a" [TypeNameRef "Null"] False] [])], 55 | TopLevelType "B" [InlineTypeDef (TypeDef [Field True "b" [TypeNameRef "Null"] False] [])] 56 | ], "") 57 | it "parses a type with all optional fields" $ 58 | _parse "type X = { x?: string }" `shouldBe` Right ([TopLevelType "X" [ 59 | InlineTypeDef (TypeDef [Field {required = False, fieldName = "x", typeRefs = [TypeNameRef "string"], constant = False}] [])]] 60 | ,"") 61 | it "parses a pathStatic in pathParts" $ 62 | _apply _pathParts "hello" `shouldBe` Right ([PathPartStatic "hello"],"") 63 | it "parses a pathVar in pathParts" $ 64 | _apply _pathParts "{hello}" `shouldBe` Right ([PathPartVar "hello"],"") 65 | it "parses a pathWild in pathParts" $ 66 | _apply _pathParts "{hello=**}" `shouldBe` Right ([PathPartWild "hello"],"") 67 | it "parses a pathStatic and Var in pathParts" $ 68 | _apply _pathParts "hello/{world=**}" `shouldBe` Right ([ 69 | PathPartStatic "hello", 70 | PathPartWild "world" 71 | ],"") 72 | it "parses a pathStatic, Wild and Var in pathParts" $ 73 | _apply _pathParts "hello/{pretty}/{world=**}" `shouldBe` Right ([ 74 | PathPartStatic "hello", 75 | PathPartVar "pretty", 76 | PathPartWild "world" 77 | ],"") 78 | 79 | it "parses a full path" $ 80 | _apply _path (unlines 81 | [ "match /hello/{pretty}/{world=**} is Rough {" 82 | , "}" 83 | ]) `shouldBe` Right (PathDef [ 84 | PathPartStatic "hello", 85 | PathPartVar "pretty", 86 | PathPartWild "world" 87 | ] [ TypeNameRef "Rough"] [], "\n") 88 | it "parses a path directive" $ 89 | _apply _pathDir "allow read: if 1<3 && true;" `shouldBe` 90 | Right (PathDirective [ "read" ] "1 < 3 && true", "") 91 | 92 | 93 | 94 | it "parses a path without className" $ 95 | _parse (unlines 96 | [ "match /stat/{var}/{wild=**} {" 97 | , " allow read: if 1>2 && 3<4;" 98 | , "}" 99 | ]) `shouldBe` Right ([ 100 | TopLevelPath (PathDef [ 101 | PathPartStatic "stat", 102 | PathPartVar "var", 103 | PathPartWild "wild" 104 | ] [] [PathBodyDir (PathDirective ["read"] "1 > 2 && 3 < 4")]) 105 | ],"") 106 | it "parses a field and a path" $ 107 | _parse (unlines 108 | [ "type A = {a: String}" 109 | , "match /stat/{var}/{wild=**} {" 110 | , " allow read: if 1>2 && 3<4;" 111 | , "}" 112 | ]) `shouldBe` Right ([ 113 | TopLevelType "A" [InlineTypeDef (TypeDef [Field True "a" [TypeNameRef "String"] False] [])], 114 | TopLevelPath (PathDef [ 115 | PathPartStatic "stat", 116 | PathPartVar "var", 117 | PathPartWild "wild" 118 | ] [] [ 119 | PathBodyDir (PathDirective ["read"] "1 > 2 && 3 < 4") 120 | ]) 121 | ],"") 122 | 123 | it "parses a one-line path" $ 124 | _parse "match a/{x=**} is X {allow create: if true;}" 125 | `shouldBe` Right ([TopLevelPath (PathDef [ 126 | PathPartStatic "a", PathPartWild "x" 127 | ] [ TypeNameRef "X"] [ 128 | PathBodyDir (PathDirective ["create"] "true") 129 | ])], "") 130 | it "fails on just the type keyword" $ do 131 | _parse "type" `shouldBe` failure ("type name missing", 0, 4) 132 | it "fails when type definition is missing =" $ 133 | _parse "type X {}" `shouldBe` failure ("missing `=` after type name", 0, 6) 134 | it "fails when there is nothing after assignment" $ do 135 | _parse "type X =" `shouldBe` failure ("type `X` is missing definition", 0, 8) 136 | it "allows a semicolon after type definition" $ do 137 | _parse "type X = string;" `shouldBe` Right ([TopLevelType "X" [TypeNameRef "string" ]], "") 138 | 139 | it "fails when string type contains a /" $ 140 | _parse "type X = {\"abc/def\": float}" `shouldBe` failure ("field `abc/def` contains illegal character '/'", 0, 19) 141 | it "fails when string prop name == '.'" $ 142 | _parse "type X = {\".\": float}" `shouldBe` failure ("field `.` cannot consist entirely of periods",0,13) 143 | it "fails when string prop name == '..'" $ 144 | _parse "type X = {\"..\": float}" `shouldBe` failure ("field `..` cannot consist entirely of periods",0,14) 145 | it "fails when string prop name == '...'" $ 146 | _parse "type X = {\"...\": float}" `shouldBe` failure ("field `...` cannot consist entirely of periods",0,15) 147 | it "fails when string prop name == '__abc__'" $ 148 | _parse "type X = {\"__abc__\": float}" `shouldBe` failure ("field `__abc__` cannot match __.*__",0,19) 149 | 150 | it "gets the quoted prop name" $ 151 | _parse "type X = {\"hello\": float}" `shouldBe` Right ([TopLevelType "X" [InlineTypeDef (TypeDef {typeDefMembers = [Field {required = True, fieldName = "\"hello\"", typeRefs = [TypeNameRef "float"], constant = False}], typeDefValidations = []})]],"") 152 | it "gets the quoted prop name with dot" $ 153 | _parse "type X = {\"hello.\": float}" `shouldBe` Right ([TopLevelType "X" [InlineTypeDef (TypeDef {typeDefMembers = [Field {required = True, fieldName = "\"hello.\"", typeRefs = [TypeNameRef "float"], constant = False}], typeDefValidations = []})]],"") 154 | it "gets the quoted prop name with excaped quote" $ 155 | _parse "type X = {\"hello\\\"\": float}" `shouldBe` Right ([TopLevelType "X" [InlineTypeDef (TypeDef {typeDefMembers = [Field {required = True, fieldName = "\"hello\\\"\"", typeRefs = [TypeNameRef "float"], constant = False}], typeDefValidations = []})]],"") 156 | 157 | it "fails when type is number" $ 158 | _parse "type X = {a: number}" `shouldBe` failure ("type 'number' is disallowed to avoid a common source of confusion",0,19) 159 | 160 | it "parses regex" $ do 161 | _parse "match /x/x { allow write: if name.match('test'); }" `shouldBe` Right ([TopLevelPath (PathDef [PathPartStatic "x",PathPartStatic "x"] [] [PathBodyDir (PathDirective ["write"] "name.match('test')")])],"") 162 | it "parses a regex call with other condition" $ do 163 | _parse "match /f/{x} {\n allow write: if 1+2==2 && x.match('^he..o'); \n}" `shouldBe` 164 | Right ([TopLevelPath (PathDef [PathPartStatic "f",PathPartVar "x"] [] [PathBodyDir (PathDirective ["write"] "1 + 2 == 2 && x.match('^he..o')")])],"") 165 | 166 | it "fails on a property without a definition" $ do 167 | _parse "type X = {fff}" `shouldBe` failure ("type `X` is missing definition", 0, 8) 168 | it "fails when a field lacks a type" $ do 169 | _parse "type X = {a: }" `shouldBe` failure ("field `a` lacks a type", 0, 12) 170 | 171 | it "fails when a function is missing a name" $ do 172 | _parse "function (abc) {}" `shouldBe` failure ("missing function name", 0, 8) 173 | it "fails when a function is missing parameter parens" $ do 174 | _parse "function z {}" `shouldBe` failure ("function `z` is missing the parameter list", 0, 10) 175 | it "fails when function is missing opening {" $ do 176 | _parse "function z() }" `shouldBe` failure ("function `z` is missing an opening `{`",0,12) 177 | it "fails when function is missing closing }" $ do 178 | _parse "function z() {" `shouldBe` failure ("function `z` is missing a closing `}`",0,14) 179 | it "fails when function body is missing" $ do 180 | _parse "function z() { \n }" `shouldBe` failure ("function `z` is missing a body", 1,3) 181 | it "fails when path has an `is` but no type" $ do 182 | _parse "match /x is {}" `shouldBe` failure ("expected a `{`", 0, 14) 183 | 184 | it "parses a string" $ do 185 | (_apply _string "\"abc\"") `shouldBe` Right ("\"abc\"", "") 186 | it "parses a string with semicolons" $ do 187 | (_apply _string "\"abc;\"") `shouldBe` Right ("\"abc;\"", "") 188 | it "parses a string with foreign quotes" $ do 189 | (_apply _string "\"abc'\"") `shouldBe` Right ("\"abc'\"", "") 190 | it "parses a string with own but escaped quotes" $ do 191 | (_apply _string "\"abc\\\"\"") `shouldBe` Right ("\"abc\\\"\"", "") 192 | 193 | it "allows for strings with semicolons in rule conditions" $ do 194 | _parse "match /x { allow read: if x==\"123;\" && y-3==4 ; }" `shouldBe` Right ([TopLevelPath (PathDef [PathPartStatic "x"] [] [PathBodyDir (PathDirective ["read"] "x == \"123;\" && y - 3 == 4")])],"") 195 | 196 | it "parses multiple directives without semicolon separators" $ do 197 | let r = unlines 198 | [ "match /x {" 199 | , " allow read: true" 200 | , " function z() { true }" 201 | , " allow create: false" 202 | , " match /q {}" 203 | , "}" 204 | ] 205 | _parse r `shouldBe` Right ([ TopLevelPath (PathDef [PathPartStatic "x"] [] 206 | [ PathBodyDir (PathDirective ["read"] "true") 207 | , PathBodyFunc (FuncDef "z" [] [] "true") 208 | , PathBodyDir (PathDirective ["create"] "false") 209 | , PathBodyPath (PathDef [PathPartStatic "q"] [] []) 210 | ])], "") 211 | 212 | describe "escape" $ do 213 | it "detects escaped chars" $ 214 | _apply (escape '\'') "\\''" `shouldBe` Right ("\\'", "'") 215 | it "detects many escaped chars" $ 216 | _apply (many $ escape '\'') "\\'\\'x" `shouldBe` Right (["\\'", "\\'"], "x") 217 | it "detects an escaped char or a arbitrary" $ 218 | _apply (escape '\'' <|> _const "f") "\\'x" `shouldBe` Right ("\\'", "x") 219 | it "detects many of an escaped char or arbitrary" $ 220 | _apply (many $ escape '\'' <|> _const "f") "\\'f\\'fx" `shouldBe` 221 | Right (["\\'", "f", "\\'", "f"], "x") 222 | it "detects many of an escaped char or many sats" $ 223 | _apply (many $ escape '\'' <|> (:[]) <$> (sat (/='0'))) "\\'f\\'fx" `shouldBe` 224 | Right (["\\'", "f", "\\'", "f", "x"], "") 225 | 226 | it "parses a string" $ 227 | _apply _string "'hello world' + 3" 228 | `shouldBe` Right ("'hello world'", " + 3") 229 | it "parses a string with escaped quotes" $ 230 | _apply _string "'hello \\'world' + 3" 231 | `shouldBe` Right ("'hello \\'world'", " + 3") 232 | it "parses a one-line function" $ 233 | _parse "function abc(h) { return x.y || b }" 234 | `shouldBe` 235 | Right ([TopLevelFunc (FuncDef "abc" ["h"] [] "x.y || b")],"") 236 | it "parses a multiline function" $ 237 | _parse (unlines [ 238 | "function abc(h) { ", 239 | " return x.y || b", 240 | "}" 241 | ]) 242 | `shouldBe` 243 | Right ([TopLevelFunc (FuncDef "abc" ["h"] [] "x.y || b")],"") 244 | it "parses a complex type" $ 245 | _parse (unlines [ 246 | "type Zxx = Null | { ", 247 | " one: X,", 248 | " two: {three: Z, four: P|X}", 249 | "}" 250 | ]) 251 | `shouldBe` 252 | Right ([ TopLevelType "Zxx" [TypeNameRef "Null" , InlineTypeDef (TypeDef [ 253 | Field True "one" [TypeNameRef "X" ] False, 254 | Field True "two" [InlineTypeDef (TypeDef [ 255 | Field True "three" [TypeNameRef "Z" ] False, 256 | Field True "four" [TypeNameRef "P" , TypeNameRef "X" ] False 257 | ] [])] False 258 | ] [])]],"") 259 | it "parses a function that returns a string" $ 260 | _parse "function q(a) { return 'p' }" `shouldBe` Right ([TopLevelFunc (FuncDef "q" ["a"] [] "'p'")], "") 261 | 262 | it "parses a function a type and a path" $ 263 | _parse (unlines [ 264 | "type Zxx = { ", 265 | " one: X,", 266 | " two: Y", 267 | "}", 268 | 269 | "function abc(h) { ", 270 | " return x.y || b", 271 | "}" 272 | ]) 273 | `shouldBe` 274 | Right ([ TopLevelType "Zxx" [InlineTypeDef (TypeDef [Field True "one" [TypeNameRef "X" ] False, Field True "two" [TypeNameRef "Y" ] False] [])], 275 | TopLevelFunc (FuncDef "abc" ["h"] [] "x.y || b") 276 | ],"") 277 | it "parses a complex path" $ 278 | _parse (unlines [ 279 | "match /x is A {", 280 | " match /y is B {", 281 | " allow read, write: if true;", 282 | " allow create, write: if false;", 283 | " function qqq(a,b,c) {", 284 | " return 123", 285 | " }", 286 | " }", 287 | "}" 288 | ]) `shouldBe` Right ([ TopLevelPath (PathDef [PathPartStatic "x"] [ TypeNameRef "A" ] [ 289 | PathBodyPath (PathDef [PathPartStatic "y"] [ TypeNameRef "B" ] [ 290 | PathBodyDir (PathDirective ["read","write"] "true"), 291 | PathBodyDir (PathDirective ["create","write"] "false"), 292 | PathBodyFunc (FuncDef "qqq" ["a","b","c"] [] "123")]) 293 | ]) 294 | ],"") 295 | describe "_topLevelOptVar" $ do 296 | it "parses" $ 297 | _parse "fff = '2'" `shouldBe` Right ([ TopLevelOpt "fff" "'2'"], "") 298 | 299 | 300 | 301 | --------------------------------------------------------------------------------