├── .gitattributes ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── Web.Debug.config ├── Web.config ├── app.js ├── bin ├── ChangeConfig.ps1 ├── Microsoft.NodejsTools.WebRole.dll ├── download.ps1 ├── node.cmd ├── setup_web.cmd └── www ├── bower.json ├── config ├── azure.json ├── default.json ├── redis.json └── test.json ├── http.js ├── models ├── Patch.js ├── RepositoryIndex.js ├── Rule.js └── rules │ ├── AlwaysOffRule.js │ ├── AlwaysOnRule.js │ ├── ArrayContainsRule.js │ ├── StringMatchesRule.js │ └── index.js ├── package.json ├── providers ├── DataProvider.js ├── DocumentDbDataProvider.js ├── InProcessDataProvider.js └── RedisDataProvider.js ├── public ├── js │ └── rennet.js └── stylesheets │ ├── style.css │ └── style.styl ├── rennet.njsproj ├── rennet.sln ├── routes ├── api.js └── index.js ├── services ├── DataService.js ├── PatchService.js ├── RennetService.js └── RepositoryIndexService.js ├── tests ├── dataProviderTests.js ├── demo.cmd ├── demo.sh ├── patchTests.js ├── rennetServiceTests.js └── ruleTests.js └── views ├── error.jade ├── index.jade └── layout.jade /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 25 | node_modules 26 | bower_components 27 | 28 | # Users Environment Variables 29 | .lock-wscript 30 | 31 | # ========================= 32 | # Operating System Files 33 | # ========================= 34 | 35 | # OSX 36 | # ========================= 37 | 38 | .DS_Store 39 | .AppleDouble 40 | .LSOverride 41 | 42 | # Icon must end with two \r 43 | Icon 44 | 45 | 46 | # Thumbnails 47 | ._* 48 | 49 | # Files that might appear on external disk 50 | .Spotlight-V100 51 | .Trashes 52 | 53 | # Directories potentially created on remote AFP share 54 | .AppleDB 55 | .AppleDesktop 56 | Network Trash Folder 57 | Temporary Items 58 | .apdisk 59 | 60 | # Windows 61 | # ========================= 62 | 63 | # Windows image file caches 64 | Thumbs.db 65 | ehthumbs.db 66 | 67 | # Folder config file 68 | Desktop.ini 69 | 70 | # Recycle Bin used on file shares 71 | $RECYCLE.BIN/ 72 | 73 | # Windows Installer files 74 | *.cab 75 | *.msi 76 | *.msm 77 | *.msp 78 | 79 | # Node tools for Visual Studio 80 | .ntvs_analysis.dat 81 | *.suo 82 | 83 | #WebStorm/IntelliJ projects 84 | .idea -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | Feel free to fork and submit pull requests. File issues if you have them. 3 | 4 | This project is maintained by JD Conley (jd@realcrowd.com). Contact him if you have any thoughts or questions. 5 | 6 | ## Dev environment setup in Visual Studio / Windows 7 | 1. Install Msysgit (http://msysgit.github.io/) 8 | - Make sure to choose the "Run Git from the Windows Command Prompt" option during setup, which will add Git to your path 9 | 2. Install node.js, Visual Studio 2013, and Node.js Tools for Visual Studio (https://nodejstools.codeplex.com/wikipage?title=Installation) 10 | 3. Open project, right click the npm node in solution explorer and choose "Install missing npm packages" 11 | 4. Open Node.js interactive window in Visual Studio (View -> Other Windows -> Node.js Interactive Window) 12 | 5. Install bower globally so it is available in your command prompt: 13 | 14 | ``` 15 | > .npm install -g bower 16 | ``` 17 | 18 | 6. Open a command prompt in the project root (rennet/rennet) and install/restore bower packages 19 | 20 | ``` 21 | > bower install 22 | ``` 23 | 24 | ## Running the tests 25 | Mocha tests are in the "tests" directory. They should be run with environment variable NODE_ENV=test. We currently run them from within WebStorm or Visual Studio. 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 RealCrowd, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rennet 2 | Rennet is a JSON document database that enables branching, chaining and merging of documents based on a simple rules pipeline. This allows you to read data that is transformed dependent on the current context of the application. Let's call this "context-aware data". 3 | 4 | A clean separation of metadata and code is a key enabler for rapid development on teams. It enables developers to continuously ship code and limits the impact of bugs. With context-aware data provided by something like rennet, developers can ship code, enable QA to test it, expose it to subsets of users/environments, and can place ultimate control of the product in the hands of the product owner. 5 | 6 | In video games this is very common practice, with data files defining everything about the game. Nowadays game data is modified on the fly in production over the internet, and integrated with A/B testing, in order to increase key performance indicators. It is less common in web or app development, but just as useful. At [RealCrowd](https://www.realcrowd.com) we use this primarily to simplify parallel development and continuous integration, with developers committing to the master branch and deploying to production often with limited impact on the end user. 7 | 8 | Some especially useful areas for context-aware data: 9 | 10 | 1. Environment and user-based configuration of features 11 | 2. Authorization 12 | 3. A/B testing 13 | 4. Dynamic configuration in general 14 | 5. Localization 15 | 16 | Rennet is currently an api-only system, but we have plans to build a management UI for it. By default it starts up with in-process ephemeral storage. This makes development and testing fast, but is not usable in production. A [Redis](http://redis.io/) as well as an [Azure DocumentDb](http://azure.microsoft.com/en-us/services/documentdb/) storage provider are included for persistent storage of documents. Data storage is abstracted into what we call [Providers](https://github.com/realcrowd/rennet/tree/master/providers) so new storage options are easy to create. 17 | 18 | ## Usage 19 | Imagine you have an application called "GitHub" that enables collaboration around git repositories. You want to both make some money and encourage open source development, so you decide to charge users for the ability to host private repositories. You want to set different pricing tiers and offer a different number of private repositories at each tier. This is pretty easy to do in code or with any ole database. But, things get a bit more complex if, say, you want to A/B test the number of tiers, or only roll it out to a percentage of your user base, or have different settings for the QA environment. The code for these scenarios can turn to spaghetti very quickly. 20 | 21 | With this configuration example, rennet will toggle the configuration for the private repositories feature in different deployment environments and for users with different pricing tiers in production. This creates a nice separation of concerns as the GitHub application no longer needs to have code for _why_ a given feature is on or off in the current context, just that it is. 22 | 23 | Check out the demo script in the [tests](https://github.com/realcrowd/rennet/tree/master/tests) directory if you want to run this entire usage example from one script. Curl is required to be in the path and rennet must be running locally on port 1337 for the script to run. 24 | 25 | ### Create a Repository 26 | This creates an empty repository with an ID of "github". You can store any other data you'd like on this document, but id is required. Later we'll add some branches into this repository to start exposing transformed data. 27 | 28 | ``` 29 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"github\"}" http://localhost:1337/api/v1/repository/github 30 | ``` 31 | 32 | ### Add Patches to Repository 33 | All documents stored in the database are patches. Patches are ultimately grouped together in branches, and applied in the order specified in the branch. Each patch also has a rule that determines if it will be applied in the current context. 34 | 35 | Let's create a few patches. We'll max out the "privateRepository" feature in the QA environment, set it to 0 by default in production, and set the correct value for users in the paid plans. 36 | 37 | For this we'll use the [StringMatchesRule](https://github.com/realcrowd/rennet/blob/master/models/rules/StringMatchesRule.js) to determine the plan that the user is a part of and apply the correct patch to the features data. See the [rules directory](https://github.com/realcrowd/rennet/tree/master/models/rules) for a list of the supported rules. 38 | 39 | ``` 40 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"defaultFeatures\",\"data\":{\"features\":{\"privateRepository\":0}}}" http://localhost:1337/api/v1/repository/github/patch/defaultFeatures 41 | 42 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"microFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"micro\"}},\"data\":{\"features\":{\"privateRepository\":5}}}" http://localhost:1337/api/v1/repository/github/patch/microFeatures 43 | 44 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"smallFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"small\"}},\"data\":{\"features\":{\"privateRepository\":10}}}" http://localhost:1337/api/v1/repository/github/patch/smallFeatures 45 | 46 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"mediumFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"medium\"}},\"data\":{\"features\":{\"privateRepository\":20}}}" http://localhost:1337/api/v1/repository/github/patch/mediumFeatures 47 | 48 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"largeFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"large\"}},\"data\":{\"features\":{\"privateRepository\":50}}}" http://localhost:1337/api/v1/repository/github/patch/largeFeatures 49 | 50 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"testingFeatures\",\"data\":{\"features\":{\"privateRepository\":1000}}}" http://localhost:1337/api/v1/repository/github/patch/testingFeatures 51 | ``` 52 | 53 | ### Create Branches with Patches 54 | Now we'll update the repository index to define a few branches and put the patches we created to use. We define a "master" branch that holds the default configuration for the application. We define a "qa" branch which contains all the patches from the "master" branch, but also applies the "testingFeatures" patch. We also define a "prod" branch that contains the "master" patches and all the patches with rules based on the user's plan. 55 | 56 | In a typical application the qa and prod environments will have much different configurations with various features being in different states of testing, different percentage of the user base with the feature enabled, etc. 57 | 58 | ``` 59 | curl -X PUT -H "Content-Type: application/json" -d "{\"id\":\"github\",\"branches\":{\"master\":{\"patches\":[\"defaultFeatures\"]},\"qa\":{\"patches\":[\"branch:master\",\"testingFeatures\"]},\"prod\":{\"patches\":[\"branch:master\",\"microFeatures\",\"smallFeatures\",\"mediumFeatures\",\"largeFeatures\"]}}}" http://localhost:1337/api/v1/repository/github 60 | ``` 61 | 62 | ### Apply a Branch to a Context 63 | Now we can use all this data we stored. POST to the repository and branch you want to use with your application's context. In this case we're just including the expected "user.plan" object that is used in the patch rules. You might also include user id, user roles, a/b test group id, data center id, operating system type, computer name, etc to further vary the data. 64 | 65 | Notice we also include a "data" node in the context. The patches apply directly to the context at the node that is specified in the [patch](https://github.com/realcrowd/rennet/blob/master/models/Patch.js). The default location is "$.data", but you can change that in the patch. We use the [JSONPath package](https://www.npmjs.org/package/JSONPath) for locating where in the document hierarchy to apply patches and evaluate rules. 66 | 67 | ``` 68 | curl -X POST -H "Content-Type: application/json" -d "{\"user\":{\"plan\":\"free\"},\"data\":{}}" http://localhost:1337/api/v1/repository/github/branch/qa/context 69 | 70 | curl -X POST -H "Content-Type: application/json" -d "{\"user\":{\"plan\":\"free\"},\"data\":{}}" http://localhost:1337/api/v1/repository/github/branch/prod/context 71 | 72 | curl -X POST -H "Content-Type: application/json" -d "{\"user\":{\"plan\":\"medium\"},\"data\":{}}" http://localhost:1337/api/v1/repository/github/branch/prod/context 73 | ``` 74 | 75 | ## How to run me 76 | 77 | ``` 78 | git clone https://github.com/realcrowd/rennet.git 79 | 80 | cd rennet 81 | 82 | npm install -g bower 83 | 84 | npm install 85 | 86 | bower install 87 | 88 | node http.js 89 | ``` 90 | 91 | ## Contributing 92 | See our [Contributing](https://github.com/realcrowd/rennet/blob/master/CONTRIBUTING.md) doc for details on contributing. Just do it. 93 | 94 | ## TODO 95 | * Setup 'npm test' 96 | * DELETE support 97 | * Etag support (etag, if-match, if-none-match) for optimistic concurrency control 98 | * Logging 99 | * Performance monitoring 100 | * Load testing 101 | * Caching providers 102 | * User interface for managing patches and rules 103 | * More rules. i.e. multi rule, number comparison `>, <, >=, <=, ==, %`, user-defined script, ? 104 | * MongoDB, filesystem, other document storage options (what do you want?) 105 | * Client libraries 106 | * JSON error formatting 107 | * Authentication/Authorization 108 | * Refactor services to remove some boilerplate -------------------------------------------------------------------------------- /Web.Debug.config: -------------------------------------------------------------------------------- 1 |  2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Web.config: -------------------------------------------------------------------------------- 1 |  2 | 6 | 7 | 8 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var path = require('path'); 3 | var logger = require('morgan'); 4 | var bodyParser = require('body-parser'); 5 | 6 | var routes = require('./routes/index'); 7 | var apiRoutes = require('./routes/api'); 8 | 9 | var app = express(); 10 | 11 | // view engine setup 12 | app.set('views', path.join(__dirname, 'views')); 13 | app.set('view engine', 'jade'); 14 | 15 | app.use(logger('dev')); 16 | app.use(bodyParser.json()); 17 | app.use(bodyParser.urlencoded({ extended: false })); 18 | app.use(require('stylus').middleware(path.join(__dirname, 'public'))); 19 | app.use(express.static(path.join(__dirname, 'public'))); 20 | app.use('/bower_components', express.static(path.join(__dirname, 'bower_components'))); 21 | app.use('/', routes); 22 | app.use('/api/v1', apiRoutes); 23 | 24 | // catch 404 and forward to error handler 25 | app.use(function (req, res, next) { 26 | var err = new Error('Not Found'); 27 | err.status = 404; 28 | next(err); 29 | }); 30 | 31 | // error handlers 32 | 33 | // development error handler 34 | // will print stacktrace 35 | if (app.get('env') === 'development') { 36 | app.use(function (err, req, res, next) { 37 | res.status(err.status || 500); 38 | res.render('error', { 39 | message: err.message, 40 | error: err 41 | }); 42 | }); 43 | } 44 | 45 | // production error handler 46 | // no stacktraces leaked to user 47 | app.use(function (err, req, res, next) { 48 | res.status(err.status || 500); 49 | res.render('error', { 50 | message: err.message, 51 | error: {} 52 | }); 53 | }); 54 | 55 | module.exports = app; 56 | -------------------------------------------------------------------------------- /bin/ChangeConfig.ps1: -------------------------------------------------------------------------------- 1 | $configFile = $args[0] 2 | 3 | Write-Host "Adding iisnode section to config file '$configFile'" 4 | $config = New-Object System.Xml.XmlDocument 5 | $config.load($configFile) 6 | $xpath = $config.CreateNavigator() 7 | $parentElement = $xpath.SelectSingleNode("//configuration/configSections/sectionGroup[@name='system.webServer']") 8 | $iisnodeElement = $parentElement.SelectSingleNode("//section[@name='iisnode']") 9 | if ($iisnodeElement) { 10 | Write-Host "Removing existing iisnode section from config file '$configFile'" 11 | $iisnodeElement.DeleteSelf() 12 | } 13 | 14 | $parentElement.AppendChild("
") 15 | $config.Save($configFile) 16 | -------------------------------------------------------------------------------- /bin/Microsoft.NodejsTools.WebRole.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realcrowd/rennet/a2eae14a449c656fe0e1525bed5f30353cdb2099/bin/Microsoft.NodejsTools.WebRole.dll -------------------------------------------------------------------------------- /bin/download.ps1: -------------------------------------------------------------------------------- 1 | $runtimeUrl = $args[0] 2 | $overrideUrl = $args[1] 3 | $current = [string] (Get-Location -PSProvider FileSystem) 4 | $client = New-Object System.Net.WebClient 5 | 6 | function downloadWithRetry { 7 | param([string]$url, [string]$dest, [int]$retry) 8 | Write-Host 9 | Write-Host "Attempt: $retry" 10 | Write-Host 11 | trap { 12 | Write-Host $_.Exception.ToString() 13 | if ($retry -lt 5) { 14 | $retry=$retry+1 15 | Write-Host 16 | Write-Host "Waiting 5 seconds and retrying" 17 | Write-Host 18 | Start-Sleep -s 5 19 | downloadWithRetry $url $dest $retry $client 20 | } 21 | else { 22 | Write-Host "Download failed" 23 | throw "Max number of retries downloading [5] exceeded" 24 | } 25 | } 26 | $client.downloadfile($url, $dest) 27 | } 28 | 29 | function download($url, $dest) { 30 | Write-Host "Downloading $url" 31 | downloadWithRetry $url $dest 1 32 | } 33 | 34 | function copyOnVerify($file, $output) { 35 | Write-Host "Verifying $file" 36 | $verify = Get-AuthenticodeSignature $file 37 | Out-Host -InputObject $verify 38 | if ($verify.Status -ne "Valid") { 39 | throw "Invalid signature for runtime package $file" 40 | } 41 | else { 42 | mv $file $output 43 | } 44 | } 45 | 46 | if ($overrideUrl) { 47 | Write-Host "Using override url: $overrideUrl" 48 | $url = $overrideUrl 49 | } 50 | else { 51 | $url = $runtimeUrl 52 | } 53 | 54 | foreach($singleUrl in $url -split ";") 55 | { 56 | $suffix = Get-Random 57 | $downloaddir = $current + "\sandbox" + $suffix 58 | mkdir $downloaddir 59 | $dest = $downloaddir + "\sandbox.exe" 60 | download $singleUrl $dest 61 | $final = $downloaddir + "\runtime.exe" 62 | copyOnVerify $dest $final 63 | if (Test-Path -LiteralPath $final) 64 | { 65 | cd $downloaddir 66 | if ($host.Version.Major -eq 3) 67 | { 68 | .\runtime.exe -y | Out-Null 69 | .\setup.cmd 70 | } 71 | else 72 | { 73 | Start-Process -FilePath $final -ArgumentList -y -Wait 74 | $cmd = $downloaddir + "\setup.cmd" 75 | Start-Process -FilePath $cmd -Wait 76 | } 77 | } 78 | else 79 | { 80 | throw "Unable to verify package" 81 | } 82 | cd $current 83 | if (Test-Path -LiteralPath $downloaddir) 84 | { 85 | Remove-Item -LiteralPath $downloaddir -Force -Recurse 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /bin/node.cmd: -------------------------------------------------------------------------------- 1 | node.exe %1 %2 %3 -------------------------------------------------------------------------------- /bin/setup_web.cmd: -------------------------------------------------------------------------------- 1 | @echo on 2 | 3 | cd /d "%~dp0" 4 | 5 | if "%EMULATED%"=="true" if DEFINED APPCMD goto emulator_setup 6 | if "%EMULATED%"== "true" exit /b 0 7 | 8 | echo Granting permissions for Network Service to the web root directory... 9 | icacls ..\ /grant "Network Service":(OI)(CI)W 10 | if %ERRORLEVEL% neq 0 goto error 11 | echo OK 12 | 13 | echo Configuring powershell permissions 14 | powershell -c "set-executionpolicy unrestricted" 15 | 16 | echo Downloading and installing runtime components 17 | powershell .\download.ps1 '%RUNTIMEURL%' '%RUNTIMEURLOVERRIDE%' 18 | if %ERRORLEVEL% neq 0 goto error 19 | 20 | echo SUCCESS 21 | exit /b 0 22 | 23 | :error 24 | echo FAILED 25 | exit /b -1 26 | 27 | :emulator_setup 28 | echo Running in emulator adding iisnode to application host config 29 | FOR /F "tokens=1,2 delims=/" %%a in ("%APPCMD%") DO set FN=%%a&set OPN=%%b 30 | if "%OPN%"=="%OPN:apphostconfig:=%" ( 31 | echo "Could not parse appcmd '%appcmd% for configuration file, exiting" 32 | goto error 33 | ) 34 | 35 | set IISNODE_BINARY_DIRECTORY=%programfiles(x86)%\iisnode-dev\release\x64 36 | set IISNODE_SCHEMA=%programfiles(x86)%\iisnode-dev\release\x64\iisnode_schema.xml 37 | 38 | if "%PROCESSOR_ARCHITECTURE%"=="AMD64" goto start 39 | set IISNODE_BINARY_DIRECTORY=%programfiles%\iisnode-dev\release\x86 40 | set IISNODE_SCHEMA=%programfiles%\iisnode-dev\release\x86\iisnode_schema_x86.xml 41 | 42 | 43 | :start 44 | set 45 | 46 | echo Using iisnode binaries location '%IISNODE_BINARY_DIRECTORY%' 47 | echo installing iisnode module using AppCMD alias %appcmd% 48 | %appcmd% install module /name:"iisnode" /image:"%IISNODE_BINARY_DIRECTORY%\iisnode.dll" 49 | 50 | set apphostconfigfile=%OPN:apphostconfig:=% 51 | powershell -c "set-executionpolicy unrestricted" 52 | powershell .\ChangeConfig.ps1 %apphostconfigfile% 53 | if %ERRORLEVEL% neq 0 goto error 54 | 55 | copy /y "%IISNODE_SCHEMA%" "%programfiles%\IIS Express\config\schema\iisnode_schema.xml" 56 | if %ERRORLEVEL% neq 0 goto error 57 | exit /b 0 58 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var debug = require('debug')('rennet'); 3 | var app = require('../app'); 4 | 5 | app.set('port', process.env.PORT || 3000); 6 | 7 | var server = app.listen(app.get('port'), function() { 8 | debug('Express server listening on port ' + server.address().port); 9 | }); 10 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rennet", 3 | "version": "1.0.0", 4 | "authors": [ 5 | "JD Conley " 6 | ], 7 | "description": "Coagulating application metadata", 8 | "main": "app.js", 9 | "moduleType": [ 10 | "node" 11 | ], 12 | "keywords": [ 13 | "metadata", 14 | "features", 15 | "stuff" 16 | ], 17 | "license": "MIT", 18 | "homepage": "https://github.com/realcrowd/rennet", 19 | "private": true, 20 | "ignore": [ 21 | "**/.*", 22 | "node_modules", 23 | "bower_components", 24 | "test", 25 | "tests" 26 | ], 27 | "dependencies": { 28 | "angularjs": "~1.3.2", 29 | "bootstrap": "~3.3.0", 30 | "html5shiv": "~3.7.2", 31 | "respond": "~1.4.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /config/azure.json: -------------------------------------------------------------------------------- 1 | { 2 | "PatchService": { 3 | "dataProvider":{ 4 | "module": "DocumentDbDataProvider", 5 | "configKey": "DocumentDb" 6 | } 7 | }, 8 | "RepositoryIndexService": { 9 | "dataProvider":{ 10 | "module": "DocumentDbDataProvider", 11 | "configKey": "DocumentDb" 12 | } 13 | }, 14 | "DocumentDb": { 15 | "urlConnection": "https://rennet-oss.documents.azure.com:443/", 16 | "auth": {"masterKey":"7rvoVz+UvuFOnxjCGbMilgdRlJX3r1KS8VWwaYVoDWo87KbXggUqa98shuTAVCeW9kn3a2I1PwH5qMB+Q659ng=="}, 17 | "databaseDefinition": {"id": "rennet-demo"}, 18 | "collectionDefinition": {"id": "rennet"}, 19 | "connectionPolicy": null, 20 | "consistencyLevel": "Session" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "PatchService": { 3 | "dataProvider": { 4 | "module": "InProcessDataProvider" 5 | } 6 | }, 7 | "RepositoryIndexService": { 8 | "dataProvider": { 9 | "module": "InProcessDataProvider" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /config/redis.json: -------------------------------------------------------------------------------- 1 | { 2 | "PatchService": { 3 | "dataProvider":{ 4 | "module": "RedisDataProvider", 5 | "configKey": "Redis" 6 | } 7 | }, 8 | "RepositoryIndexService": { 9 | "dataProvider":{ 10 | "module": "RedisDataProvider", 11 | "configKey": "Redis" 12 | } 13 | }, 14 | "Redis": { 15 | "host": "127.0.0.1", 16 | "port": 6379, 17 | "options": {} 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "DocumentDb": { 3 | "urlConnection": "https://rennet-oss.documents.azure.com:443/", 4 | "auth": {"masterKey":"7rvoVz+UvuFOnxjCGbMilgdRlJX3r1KS8VWwaYVoDWo87KbXggUqa98shuTAVCeW9kn3a2I1PwH5qMB+Q659ng=="}, 5 | "databaseDefinition": {"id": "rennet-demo"}, 6 | "collectionDefinition": {"id": "rennet-tests"}, 7 | "connectionPolicy":null, 8 | "consistencyLevel":"Session" 9 | }, 10 | "Redis": { 11 | "host": "127.0.0.1", 12 | "port": 6379, 13 | "options": {} 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /http.js: -------------------------------------------------------------------------------- 1 | var app = require('./app'); 2 | var http = require('http'); 3 | 4 | app.set('port', process.env.PORT || 1337); 5 | 6 | var server = http.createServer(app); 7 | server.listen(app.get('port'), function(){ 8 | console.log("Rennet server listening on port " + app.get('port')); 9 | }); 10 | -------------------------------------------------------------------------------- /models/Patch.js: -------------------------------------------------------------------------------- 1 | var Q = require('q'); 2 | var extend = require('extend'); 3 | var jsonPathEngine = require('JSONPath'); 4 | 5 | var Patch = function (obj) { 6 | this.id = null; 7 | this.data = {}; 8 | this.applyAtJsonPath = "$.data"; //apply at the "data" node of the context by default 9 | this.rule = null; 10 | 11 | for (var prop in obj) { 12 | this[prop] = obj[prop]; 13 | } 14 | 15 | //Setup expected prototype for the rule 16 | if (this.rule && this.rule.name) { 17 | var RuleConstructor = require('./rules')[this.rule.name]; 18 | this.rule = new RuleConstructor(this.rule); 19 | } 20 | }; 21 | 22 | Patch.prototype.shouldApply = function (context) { 23 | //No rule means apply no matter what, for simplicity of the JSON. 24 | if (!this.rule) { 25 | return Q(true); 26 | } 27 | 28 | return this.rule.evaluate(context); 29 | }; 30 | 31 | Patch.prototype.apply = function (context) { 32 | var matchingNodes = jsonPathEngine.eval(context, this.applyAtJsonPath); 33 | if (matchingNodes.length == 0) { 34 | throw new Error('Nothing found in context at "' + this.applyAtJsonPath + '". Unable to apply patch.') 35 | } 36 | 37 | //go patch all the paths it found 38 | var len = matchingNodes.length; 39 | for (var i = 0; i < len; i++) { 40 | extend(true, matchingNodes[i], this.data); 41 | } 42 | 43 | return Q(context); 44 | }; 45 | 46 | module.exports = Patch; -------------------------------------------------------------------------------- /models/RepositoryIndex.js: -------------------------------------------------------------------------------- 1 | var RepositoryIndex = function (obj) { 2 | this.id = null; 3 | this.branches = {}; 4 | 5 | for (var prop in obj) { 6 | this[prop] = obj[prop]; 7 | } 8 | }; 9 | 10 | module.exports = RepositoryIndex; -------------------------------------------------------------------------------- /models/Rule.js: -------------------------------------------------------------------------------- 1 | var Rule = function () { 2 | this.name = null; 3 | this.arguments = {}; 4 | }; 5 | 6 | Rule.prototype.evaluate = function (context) { 7 | throw new Error("Base Rule cannot be evaluated. Use one of the subclasses in the 'rules' namespace instead."); 8 | }; 9 | 10 | module.exports = Rule; -------------------------------------------------------------------------------- /models/rules/AlwaysOffRule.js: -------------------------------------------------------------------------------- 1 | var Rule = require('../Rule'); 2 | var Q = require('q'); 3 | 4 | var AlwaysOffRule = function (obj) { 5 | this.name = "AlwaysOffRule"; 6 | this.arguments = {}; 7 | 8 | for (var prop in obj) { 9 | this[prop] = obj[prop]; 10 | } 11 | }; 12 | 13 | AlwaysOffRule.prototype = new Rule(); 14 | 15 | AlwaysOffRule.prototype.evaluate = function(context) { 16 | return Q(false); 17 | }; 18 | 19 | module.exports = AlwaysOffRule; -------------------------------------------------------------------------------- /models/rules/AlwaysOnRule.js: -------------------------------------------------------------------------------- 1 | var Rule = require('../Rule'); 2 | var Q = require('q'); 3 | 4 | var AlwaysOnRule = function (obj) { 5 | this.name = "AlwaysOnRule"; 6 | this.arguments = {}; 7 | 8 | for (var prop in obj) { 9 | this[prop] = obj[prop]; 10 | } 11 | }; 12 | 13 | AlwaysOnRule.prototype = new Rule(); 14 | 15 | AlwaysOnRule.prototype.evaluate = function(context) { 16 | return Q(true); 17 | }; 18 | 19 | module.exports = AlwaysOnRule; -------------------------------------------------------------------------------- /models/rules/ArrayContainsRule.js: -------------------------------------------------------------------------------- 1 | var Rule = require('../Rule'); 2 | var jsonPathEngine = require('JSONPath'); 3 | var Q = require('q'); 4 | 5 | var ArrayContainsRule = function (obj) { 6 | this.name = "ArrayContainsRule"; 7 | this.arguments = {}; 8 | 9 | for (var prop in obj) { 10 | this[prop] = obj[prop]; 11 | } 12 | }; 13 | ArrayContainsRule.prototype = new Rule(); 14 | 15 | ArrayContainsRule.prototype.arguments = { 16 | jsonPath: '', //Where on the context this array lives 17 | matches: '' //Regex match for the item in the array 18 | }; 19 | 20 | ArrayContainsRule.prototype.evaluate = function (context) { 21 | var arrayToEvaluate = jsonPathEngine.eval(context, this.arguments.jsonPath); 22 | 23 | if (!Array.isArray(arrayToEvaluate)) { 24 | return Q(false); 25 | } 26 | 27 | var rx = new RegExp(this.arguments.matches); 28 | var len = arrayToEvaluate.length; 29 | for (var i = 0; i < len; i++) { 30 | if (String(arrayToEvaluate[i]).match(rx) !== null) { 31 | return Q(true); 32 | } 33 | } 34 | 35 | return Q(false); 36 | }; 37 | 38 | module.exports = ArrayContainsRule; -------------------------------------------------------------------------------- /models/rules/StringMatchesRule.js: -------------------------------------------------------------------------------- 1 | var Rule = require('../Rule'); 2 | var jsonPathEngine = require('JSONPath'); 3 | var Q = require('q'); 4 | 5 | var StringMatchesRule = function (obj) { 6 | this.name = "StringMatchesRule"; 7 | this.arguments = {}; 8 | 9 | for (var prop in obj) { 10 | this[prop] = obj[prop]; 11 | } 12 | }; 13 | StringMatchesRule.prototype = new Rule(); 14 | 15 | StringMatchesRule.prototype.arguments = { 16 | jsonPath: '', //Where on the context this value lives (https://github.com/s3u/JSONPath) 17 | matches: '' //Regex match for the value 18 | }; 19 | 20 | StringMatchesRule.prototype.evaluate = function (context) { 21 | var matchingNodes = jsonPathEngine.eval(context, this.arguments.jsonPath); 22 | 23 | if (matchingNodes.length == 0) { 24 | throw new Error('Nothing found in context at "' + applyAtJsonPath + '". Unable to apply patch.') 25 | } 26 | 27 | //make sure the strings all match 28 | //todo: make this behavior configurable 29 | var len = matchingNodes.length; 30 | for (var i = 0; i < len; i++) { 31 | if (String(matchingNodes[i]).match(this.arguments.matches) === null) { 32 | return Q(false); 33 | } 34 | } 35 | 36 | return Q(true); 37 | }; 38 | 39 | module.exports = StringMatchesRule; -------------------------------------------------------------------------------- /models/rules/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | AlwaysOnRule:require('./AlwaysOnRule'), 3 | AlwaysOffRule:require('./AlwaysOffRule'), 4 | ArrayContainsRule:require('./ArrayContainsRule'), 5 | StringMatchesRule:require('./StringMatchesRule') 6 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rennet", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node ./bin/www" 7 | }, 8 | "description": "rennet", 9 | "author": { 10 | "name": "JD Conley", 11 | "email": "jd@realcrowd.com" 12 | }, 13 | "dependencies": { 14 | "JSONPath": "^0.10.0", 15 | "body-parser": "~1.8.1", 16 | "bower": "^1.3.12", 17 | "config": "^1.7.0", 18 | "debug": "~2.0.0", 19 | "documentdb": "0.9.1", 20 | "express": "~4.9.0", 21 | "extend": "^2.0.0", 22 | "jade": "~1.6.0", 23 | "mocha": "^2.0.1", 24 | "morgan": "~1.3.0", 25 | "node-extend": "^0.2.0", 26 | "node-uuid": "^1.4.2", 27 | "q": "^1.1.1", 28 | "redis": "^0.12.1", 29 | "serve-favicon": "~2.1.3", 30 | "stylus": "0.42.3" 31 | }, 32 | "devDependencies": { 33 | "mocha": "^2.0.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /providers/DataProvider.js: -------------------------------------------------------------------------------- 1 | var Q = require('q'); 2 | 3 | var DataProvider = function() { 4 | }; 5 | 6 | DataProvider.prototype.getDocument = function(id) { 7 | throw new Error("Not implemented"); 8 | }; 9 | 10 | DataProvider.prototype.getDocuments = function(ids) { 11 | //naive parallel implementation is the default. 12 | //smart providers will do this on their own. 13 | var dataProvider = this; 14 | return Q().then(function(){ 15 | return ids.map(dataProvider.getDocument, dataProvider); 16 | }).all(); 17 | }; 18 | 19 | DataProvider.prototype.putDocument = function(id, document) { 20 | throw new Error("Not implemented"); 21 | }; 22 | 23 | DataProvider.prototype.applyConfiguration = function(config) { 24 | throw new Error("Not implemented"); 25 | }; 26 | 27 | module.exports = DataProvider; -------------------------------------------------------------------------------- /providers/DocumentDbDataProvider.js: -------------------------------------------------------------------------------- 1 | var Q = require('q'); 2 | var DocumentClientWrapper = require('documentdb').DocumentClientWrapper; 3 | var extend = require('extend'); 4 | var DataProvider = require('./DataProvider'); 5 | 6 | var DocumentDbDataProvider = function(config) { 7 | this.client = null; 8 | this.database = null; 9 | this.collection = null; 10 | 11 | if (config) { 12 | this.applyConfiguration(config); 13 | } 14 | }; 15 | 16 | DocumentDbDataProvider.prototype = new DataProvider(); 17 | 18 | DocumentDbDataProvider.prototype.applyConfiguration = function(config) { 19 | this.config = config; 20 | 21 | this.client = new DocumentClientWrapper( 22 | this.config.urlConnection, 23 | this.config.auth, 24 | this.config.connectionPolicy, 25 | this.config.consistencyLevel); 26 | }; 27 | 28 | 29 | DocumentDbDataProvider.prototype.getDocumentClient = function() { 30 | if (this.database && this.collection && this.client) { 31 | return Q(this.client); 32 | } 33 | 34 | var that = this; 35 | 36 | return that.getOrCreateDatabase(that.client, that.config.databaseDefinition) 37 | .then(function(database) { 38 | that.database = database; 39 | return that.getOrCreateCollection(that.client, database, that.config.collectionDefinition) 40 | .then(function(collection){ 41 | that.collection = collection; 42 | return that.client; 43 | }); 44 | }); 45 | }; 46 | 47 | DocumentDbDataProvider.prototype.getDocument = function(id) { 48 | var that = this; 49 | return this.getDocumentClient() 50 | .then(function(client) { 51 | return client 52 | .queryDocuments(that.collection._self, 'SELECT * FROM root r WHERE r.id="' + that.escapeParam(id) + '"') 53 | .toArrayAsync() 54 | .then(function(queryResult){ 55 | if (queryResult.feed.length === 0) { 56 | return null; 57 | } 58 | 59 | return queryResult.feed[0]; 60 | }); 61 | }); 62 | }; 63 | 64 | DocumentDbDataProvider.prototype.putDocument = function(id, document) { 65 | var that = this; 66 | 67 | return that.getDocument(id) 68 | .then(function(updatedDocument){ 69 | if (updatedDocument) { 70 | extend(true, updatedDocument, document); 71 | 72 | return that.client.replaceDocumentAsync(updatedDocument._self, updatedDocument); 73 | } 74 | 75 | updatedDocument = { 76 | id: id 77 | }; 78 | 79 | extend(true, updatedDocument, document); 80 | 81 | return that.client.createDocumentAsync(that.collection._self, updatedDocument); 82 | }) 83 | .then(function(createOrReplaceResult){ 84 | return createOrReplaceResult.resource; 85 | }); 86 | }; 87 | 88 | DocumentDbDataProvider.prototype.escapeParam = function(paramValue) { 89 | return paramValue.replace('"', '\\"'); 90 | }; 91 | 92 | DocumentDbDataProvider.prototype.getOrCreateRootEntity = function(definition, queryFunc, queryThisArg, createFunc, createThisArg) { 93 | return queryFunc.call(queryThisArg, 'SELECT * FROM root r WHERE r.id="' + this.escapeParam(definition.id) + '"') 94 | .toArrayAsync() 95 | .then(function(queryResult) { 96 | if (queryResult.feed.length === 0) { 97 | return createFunc.call(createThisArg, definition) 98 | .then(function(createResult){ 99 | return createResult.resource; 100 | }); 101 | } 102 | 103 | return queryResult.feed[0]; 104 | }); 105 | }; 106 | 107 | DocumentDbDataProvider.prototype.getOrCreateCollection = function(client, database, collectionDefinition) { 108 | return this.getOrCreateRootEntity( 109 | collectionDefinition, 110 | function(query){return client.queryCollections(database._self, query);}, 111 | this, 112 | function(definition) { return client.createCollectionAsync(database._self, definition); }, 113 | this); 114 | }; 115 | 116 | DocumentDbDataProvider.prototype.getOrCreateDatabase = function(client, databaseDefinition) { 117 | return this.getOrCreateRootEntity( 118 | databaseDefinition, 119 | client.queryDatabases, 120 | client, 121 | client.createDatabaseAsync, 122 | client); 123 | }; 124 | 125 | module.exports = DocumentDbDataProvider; -------------------------------------------------------------------------------- /providers/InProcessDataProvider.js: -------------------------------------------------------------------------------- 1 | var DataProvider = require('./DataProvider'); 2 | var Q = require('q'); 3 | 4 | var InProcessDataProvider = function(config) { 5 | this.config = config; 6 | this.data = {}; 7 | }; 8 | 9 | InProcessDataProvider.prototype = new DataProvider(); 10 | 11 | InProcessDataProvider.prototype.getDocument = function(id) { 12 | if (!this.data.hasOwnProperty(id)) { 13 | return Q(null); 14 | } 15 | 16 | var docAsJson = this.data[id]; 17 | var document = JSON.parse(docAsJson); 18 | 19 | return Q(document); 20 | }; 21 | 22 | InProcessDataProvider.prototype.putDocument = function(id, document) { 23 | var docAsJson = JSON.stringify(document); 24 | this.data[id] = docAsJson; 25 | 26 | var cloneForReturn = JSON.parse(docAsJson); 27 | return Q(cloneForReturn); 28 | }; 29 | 30 | InProcessDataProvider.prototype.applyConfiguration = function(config) { 31 | this.config = config; 32 | }; 33 | 34 | 35 | module.exports = InProcessDataProvider; -------------------------------------------------------------------------------- /providers/RedisDataProvider.js: -------------------------------------------------------------------------------- 1 | var DataProvider = require('./DataProvider'); 2 | var Q = require('q'); 3 | var redis = require('redis'); 4 | var extend = require('extend'); 5 | 6 | var RedisDataProvider = function(config) { 7 | this.client = null; 8 | 9 | this.applyConfiguration(config); 10 | }; 11 | 12 | RedisDataProvider.prototype = new DataProvider(); 13 | 14 | RedisDataProvider.prototype.getDocumentId = function(id) { 15 | return "rennet:doc:" + id; 16 | }; 17 | 18 | RedisDataProvider.prototype.getDocument = function(id) { 19 | return Q.ninvoke(this.client, "get", this.getDocumentId(id)) 20 | .then(function(docAsJson) { 21 | return JSON.parse(docAsJson); 22 | }); 23 | }; 24 | 25 | RedisDataProvider.prototype.putDocument = function(id, document) { 26 | var docAsJson = JSON.stringify(document); 27 | return Q.ninvoke(this.client, "set", this.getDocumentId(id), docAsJson) 28 | .then(function() { 29 | return document; 30 | }); 31 | }; 32 | 33 | RedisDataProvider.prototype.applyConfiguration = function(config) { 34 | this.config = config; 35 | 36 | var defaultConfig = { 37 | host: '127.0.0.1', 38 | port: 6379, 39 | options: {} 40 | }; 41 | 42 | this.config = extend(true, defaultConfig, config); 43 | 44 | this.client = redis.createClient(this.config.port, this.config.host, this.config.options); 45 | }; 46 | 47 | 48 | module.exports = RedisDataProvider; -------------------------------------------------------------------------------- /public/js/rennet.js: -------------------------------------------------------------------------------- 1 | var rennetApp = angular.module('rennetApp', []); 2 | 3 | rennetApp.controller('RennetController', ['$scope', function($scope) { 4 | $scope.value = new Date(); 5 | }]); -------------------------------------------------------------------------------- /public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | a { 6 | color: #00b7ff; 7 | } 8 | -------------------------------------------------------------------------------- /public/stylesheets/style.styl: -------------------------------------------------------------------------------- 1 | body 2 | padding: 50px 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif 4 | a 5 | color: #00B7FF -------------------------------------------------------------------------------- /rennet.njsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 11.0 5 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) 6 | rennet 7 | rennet 8 | 9 | 10 | 11 | Debug 12 | 2.0 13 | da8ae268-f9be-4cf1-97f0-425547866e53 14 | . 15 | bin\www 16 | 17 | 18 | . 19 | . 20 | v4.0 21 | {3AF33F2E-1136-4D97-BBB7-1795711AC8B8};{349c5851-65df-11da-9384-00065b846f21};{9092AA53-FB77-4645-B42D-1CCCA6BD08BD} 22 | ShowAllFiles 23 | 1337 24 | true 25 | 26 | 27 | true 28 | 29 | 30 | true 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | Code 72 | Mocha 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | False 149 | True 150 | 0 151 | / 152 | http://localhost:48022/ 153 | False 154 | True 155 | http://localhost:1337 156 | False 157 | 158 | 159 | 160 | 161 | 162 | 163 | CurrentPage 164 | True 165 | False 166 | False 167 | False 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | False 177 | False 178 | 179 | 180 | 181 | 182 | -------------------------------------------------------------------------------- /rennet.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 2013 4 | VisualStudioVersion = 12.0.30723.0 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9092AA53-FB77-4645-B42D-1CCCA6BD08BD}") = "rennet", "rennet.njsproj", "{DA8AE268-F9BE-4CF1-97F0-425547866E53}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {DA8AE268-F9BE-4CF1-97F0-425547866E53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {DA8AE268-F9BE-4CF1-97F0-425547866E53}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {DA8AE268-F9BE-4CF1-97F0-425547866E53}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {DA8AE268-F9BE-4CF1-97F0-425547866E53}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /routes/api.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var router = express.Router(); 3 | var RennetService = require('../services/RennetService'); 4 | var rennetService = new RennetService(); 5 | 6 | var putRepository = function(req, res, next) { 7 | //TODO: handle etags 8 | 9 | rennetService.repositoryIndexService 10 | .putRepositoryIndex(req.body) 11 | .then(function(repositoryIndex){ 12 | res.send(repositoryIndex); 13 | }) 14 | .catch(next); 15 | }; 16 | 17 | /* 18 | * POST/PUT to create or update a repository 19 | */ 20 | router.post('/repository/:repositoryId', putRepository); 21 | router.put('/repository/:repositoryId', putRepository); 22 | 23 | router.get('/repository/:repositoryId', function (req, res, next) { 24 | rennetService.repositoryIndexService 25 | .getRepositoryIndex(req.params.repositoryId) 26 | .then(function(repositoryIndex){ 27 | res.send(repositoryIndex); 28 | }) 29 | .catch(next); 30 | }); 31 | 32 | var putPatch = function(req, res, next) { 33 | //TODO: handle etags 34 | 35 | rennetService.patchService 36 | .putPatch(req.params.repositoryId, req.body) 37 | .then(function(patch){ 38 | res.send(patch); 39 | }) 40 | .catch(next); 41 | }; 42 | 43 | 44 | /* 45 | * POST/PUT to create or update a patch 46 | */ 47 | router.post('/repository/:repositoryId/patch/:patchId', putPatch); 48 | router.put('/repository/:repositoryId/patch/:patchId', putPatch); 49 | 50 | router.get('/repository/:repositoryId/patch/:patchId', function (req, res, next) { 51 | rennetService.patchService 52 | .getPatch(req.params.repositoryId, req.params.patchId) 53 | .then(function(patch){ 54 | res.send(patch); 55 | }) 56 | .catch(next); 57 | }); 58 | 59 | /* 60 | * POST the context, returning the data set for the repository/branch 61 | */ 62 | router.post('/repository/:repositoryId/branch/:branchId/context', function (req, res, next) { 63 | 64 | rennetService 65 | .resolveContext(req.params.repositoryId, req.params.branchId, req.body) 66 | .then(function(context){ 67 | res.send(context); 68 | }) 69 | .catch(next); 70 | }); 71 | 72 | module.exports = router; -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var router = express.Router(); 3 | 4 | /* GET home page. */ 5 | router.get('/', function (req, res) { 6 | res.render('index', { title: 'Rennet' }); 7 | }); 8 | 9 | module.exports = router; -------------------------------------------------------------------------------- /services/DataService.js: -------------------------------------------------------------------------------- 1 | var Q = require('q'); 2 | var extend = require('extend'); 3 | var config = require('config'); 4 | 5 | var DataService = function() { 6 | }; 7 | 8 | DataService.prototype.applyConfiguration = function(configuration) { 9 | if (typeof configuration == "string" || configuration instanceof String) { 10 | //load config from config section 11 | this.configuration = config.get(configuration); 12 | } else { 13 | //load config from object 14 | this.configuration = configuration; 15 | } 16 | 17 | //load our data provider based on our configuration 18 | var DataProvider = require('../providers/' + this.configuration.dataProvider.module); 19 | this.dataProvider = new DataProvider(); 20 | var dataProviderConfig = (this.configuration.dataProvider.hasOwnProperty("configKey") && config.has(this.configuration.dataProvider.configKey)) 21 | ? config.get(this.configuration.dataProvider.configKey) 22 | : {}; 23 | this.dataProvider.applyConfiguration(dataProviderConfig); 24 | }; 25 | 26 | module.exports = DataService; -------------------------------------------------------------------------------- /services/PatchService.js: -------------------------------------------------------------------------------- 1 | var Q = require('q'); 2 | var Patch = require('../models/Patch'); 3 | var DataService = require('./DataService'); 4 | 5 | var PatchService = function(configuration){ 6 | configuration = configuration || "PatchService"; 7 | this.applyConfiguration(configuration); 8 | }; 9 | 10 | PatchService.prototype = new DataService(); 11 | 12 | PatchService.prototype.getPatchDocumentId = function(repositoryId, id) { 13 | return "Patch-" + repositoryId + "-" + id; 14 | }; 15 | 16 | PatchService.prototype.getPatch = function(repositoryId, id) { 17 | var documentId = this.getPatchDocumentId(repositoryId, id); 18 | return this.dataProvider 19 | .getDocument(documentId) 20 | .then(function(document){ 21 | return new Patch(document.data); 22 | }); 23 | }; 24 | 25 | PatchService.prototype.getPatches = function(repositoryId, patchIds) { 26 | var patchService = this; 27 | 28 | return this.dataProvider 29 | .getDocuments(patchIds.map(function(patchId) { 30 | return patchService.getPatchDocumentId(repositoryId, patchId); 31 | })) 32 | .then(function(documents) { 33 | return documents.map(function(document){ 34 | return new Patch(document.data); 35 | }); 36 | }); 37 | }; 38 | 39 | PatchService.prototype.putPatch = function(repositoryId, patch) { 40 | var documentId = this.getPatchDocumentId(repositoryId, patch.id); 41 | return this.dataProvider 42 | .putDocument(documentId, { data: patch, type: "Patch" }) 43 | .then(function(document){ 44 | return new Patch(document.data); 45 | }); 46 | }; 47 | 48 | module.exports = PatchService; -------------------------------------------------------------------------------- /services/RennetService.js: -------------------------------------------------------------------------------- 1 | var Q = require('q'); 2 | var Patch = require('../models/Patch'); 3 | var PatchService = require('./PatchService'); 4 | var RepositoryIndexService = require('./RepositoryIndexService'); 5 | 6 | var RennetService = function(configuration) { 7 | this.branchPatchIdPrefix = 'branch:'; 8 | 9 | if (configuration && configuration.patchService) { 10 | this.patchService = new PatchService(configuration.patchService); 11 | } else { 12 | this.patchService = new PatchService() 13 | } 14 | 15 | if (configuration && configuration.repositoryIndexService) { 16 | this.repositoryIndexService = new RepositoryIndexService(configuration.repositoryIndexService); 17 | } else { 18 | this.repositoryIndexService = new RepositoryIndexService(); 19 | } 20 | }; 21 | 22 | RennetService.prototype.expandPatchList = function(repositoryIndex, branch, depth) { 23 | var results = []; 24 | 25 | depth = (depth || 0) + 1; 26 | 27 | //arbitrary max depth. todo: reference loop checking 28 | if (depth > 100) { 29 | throw new Error("Max branch depth of 100 reached. Consider nesting fewer branches. Did you create an infinite reference loop?"); 30 | } 31 | 32 | var numPatchesInBranch = branch.patches.length; 33 | for (var i = 0; i < numPatchesInBranch; i++) 34 | { 35 | var patchId = branch.patches[i]; 36 | if (patchId.indexOf(this.branchPatchIdPrefix) === 0) { 37 | var subBranchId = patchId.substring(this.branchPatchIdPrefix.length); 38 | var subBranchPatchIds = this.expandPatchList(repositoryIndex, repositoryIndex.branches[subBranchId], depth); 39 | var numSubPatches = subBranchPatchIds.length; 40 | for (var j = 0; j < numSubPatches; j++) { 41 | results.push(subBranchPatchIds[j]); 42 | } 43 | } else { 44 | results.push(patchId); 45 | } 46 | } 47 | 48 | return results; 49 | }; 50 | 51 | RennetService.prototype.resolveContext = function(repositoryId, branchId, context) { 52 | if (!repositoryId) { 53 | throw new Error("A repositoryId is required"); 54 | } 55 | if (!branchId) { 56 | throw new Error("A branchId is required"); 57 | } 58 | 59 | var patchService = this.patchService; 60 | var rennetService = this; 61 | 62 | //get the repository index so we know how to apply patches 63 | return this.repositoryIndexService.getRepositoryIndex(repositoryId) 64 | .then(function(repositoryIndex) { 65 | if (!repositoryIndex) { 66 | throw new Error("Repository Index not found for " + repositoryId); 67 | } 68 | 69 | if (!repositoryIndex.branches[branchId]) { 70 | throw new Error(branchId + " branchId not found in repository index"); 71 | } 72 | 73 | //the expanded, in order, list of patches to apply 74 | var patchList = rennetService.expandPatchList(repositoryIndex, repositoryIndex.branches[branchId]); 75 | 76 | //this is going to load all the patches into memory a couple times. 77 | //might want to be smarter about this in the future and add more round trips to the provider. 78 | return patchService.getPatches(repositoryId, patchList); 79 | }) 80 | .then(function(patches) { 81 | //we have the list of patches to apply, in order. do it. 82 | //chain the promises so they execute serially and not in parallel 83 | var currentPromise = Q(); 84 | var promises = patches.map(function(patch) { 85 | return currentPromise = currentPromise.then(function() { 86 | return patch.shouldApply(context); 87 | }).then(function(shouldApply){ 88 | if (shouldApply) { 89 | return patch.apply(context); 90 | } else { 91 | return context; 92 | } 93 | }); 94 | }); 95 | 96 | return Q.all(promises) 97 | .then(function(results) { 98 | return context; 99 | }); 100 | }); 101 | }; 102 | 103 | module.exports = RennetService; -------------------------------------------------------------------------------- /services/RepositoryIndexService.js: -------------------------------------------------------------------------------- 1 | var RepositoryIndex = require('../models/RepositoryIndex'); 2 | var DataService = require('./DataService'); 3 | 4 | var RepositoryIndexService = function(configuration){ 5 | configuration = configuration || "RepositoryIndexService"; 6 | this.applyConfiguration(configuration); 7 | }; 8 | 9 | RepositoryIndexService.prototype = new DataService(); 10 | 11 | 12 | RepositoryIndexService.prototype.getRepositoryIndexDocumentId = function(repositoryId) { 13 | return "RepositoryIndex-" + repositoryId; 14 | }; 15 | 16 | RepositoryIndexService.prototype.getRepositoryIndex = function(repositoryId) { 17 | var documentId = this.getRepositoryIndexDocumentId(repositoryId); 18 | return this.dataProvider 19 | .getDocument(documentId) 20 | .then(function(document){ 21 | return new RepositoryIndex(document.data); 22 | }); 23 | }; 24 | 25 | RepositoryIndexService.prototype.putRepositoryIndex = function(repositoryIndex) { 26 | var documentId = this.getRepositoryIndexDocumentId(repositoryIndex.id); 27 | return this.dataProvider 28 | .putDocument(documentId, { data: repositoryIndex, type: "RepositoryIndex" }) 29 | .then(function(document){ 30 | return new RepositoryIndex(document.data); 31 | }); 32 | }; 33 | 34 | module.exports = RepositoryIndexService; 35 | -------------------------------------------------------------------------------- /tests/dataProviderTests.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var uuid = require('node-uuid'); 3 | var config = require('config'); 4 | var Q = require('q'); 5 | 6 | describe('Data Providers', function () { 7 | var providersToTest = [ 8 | { 9 | constructor: require('../providers/InProcessDataProvider'), 10 | configuration: null 11 | }, 12 | { 13 | constructor: require('../providers/DocumentDbDataProvider'), 14 | configuration: config.get('DocumentDb') 15 | }, 16 | { 17 | constructor: require('../providers/RedisDataProvider'), 18 | configuration: config.get('Redis') 19 | } 20 | ]; 21 | 22 | it('can CRU', function(done){ 23 | var currentPromise = Q(); 24 | var expectedObjs = []; 25 | 26 | providersToTest.map(function(providerDefinition) { 27 | var Provider = providerDefinition.constructor; 28 | var provider = new Provider(); 29 | if (providerDefinition.configuration) { 30 | provider.applyConfiguration(providerDefinition.configuration); 31 | } 32 | 33 | currentPromise = currentPromise.then(function(){ 34 | var obj = { 35 | id: uuid.v1(), 36 | branches: { 37 | blah: [uuid.v1()] 38 | }}; 39 | expectedObjs.push(obj); 40 | return provider.putDocument(obj.id, obj); 41 | }).then(function(obj){ 42 | return provider.getDocument(obj.id); 43 | }).then(function(obj){ 44 | var objToValidate = expectedObjs.shift(); 45 | assert.equal(obj.id, objToValidate.id); 46 | assert.equal(obj.branches.blah[0], objToValidate.branches.blah[0]); 47 | 48 | objToValidate.branches.blah.push(uuid.v1()); 49 | 50 | return provider 51 | .putDocument(objToValidate.id, objToValidate) 52 | .then(function(updatedObj){ 53 | return provider.getDocument(updatedObj.id); 54 | }) 55 | .then(function(readObj){ 56 | assert.equal(readObj.branches.blah[0], objToValidate.branches.blah[0]); 57 | assert.equal(readObj.branches.blah[1], objToValidate.branches.blah[1]); 58 | }); 59 | }); 60 | }); 61 | 62 | currentPromise 63 | .then(function(){ 64 | done(); 65 | }) 66 | .catch(done); 67 | }); 68 | }); -------------------------------------------------------------------------------- /tests/demo.cmd: -------------------------------------------------------------------------------- 1 | ::Create repo 2 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"github\"}" http://localhost:1337/api/v1/repository/github 3 | 4 | ::Create patches in repo 5 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"defaultFeatures\",\"data\":{\"features\":{\"privateRepository\":0}}}" http://localhost:1337/api/v1/repository/github/patch/defaultFeatures 6 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"microFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"micro\"}},\"data\":{\"features\":{\"privateRepository\":5}}}" http://localhost:1337/api/v1/repository/github/patch/microFeatures 7 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"smallFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"small\"}},\"data\":{\"features\":{\"privateRepository\":10}}}" http://localhost:1337/api/v1/repository/github/patch/smallFeatures 8 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"mediumFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"medium\"}},\"data\":{\"features\":{\"privateRepository\":20}}}" http://localhost:1337/api/v1/repository/github/patch/mediumFeatures 9 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"largeFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"large\"}},\"data\":{\"features\":{\"privateRepository\":50}}}" http://localhost:1337/api/v1/repository/github/patch/largeFeatures 10 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"testingFeatures\",\"data\":{\"features\":{\"privateRepository\":1000}}}" http://localhost:1337/api/v1/repository/github/patch/testingFeatures 11 | 12 | ::Modify repo to define branches 13 | curl -X PUT -H "Content-Type: application/json" -d "{\"id\":\"github\",\"branches\":{\"master\":{\"patches\":[\"defaultFeatures\"]},\"qa\":{\"patches\":[\"branch:master\",\"testingFeatures\"]},\"prod\":{\"patches\":[\"branch:master\",\"microFeatures\",\"smallFeatures\",\"mediumFeatures\",\"largeFeatures\"]}}}" http://localhost:1337/api/v1/repository/github 14 | 15 | ::Use various repo branches with different contexts 16 | curl -X POST -H "Content-Type: application/json" -d "{\"user\":{\"plan\":\"free\"},\"data\":{}}" http://localhost:1337/api/v1/repository/github/branch/qa/context 17 | curl -X POST -H "Content-Type: application/json" -d "{\"user\":{\"plan\":\"free\"},\"data\":{}}" http://localhost:1337/api/v1/repository/github/branch/prod/context 18 | curl -X POST -H "Content-Type: application/json" -d "{\"user\":{\"plan\":\"medium\"},\"data\":{}}" http://localhost:1337/api/v1/repository/github/branch/prod/context -------------------------------------------------------------------------------- /tests/demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | #Create repo 4 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"github\"}" http://localhost:1337/api/v1/repository/github 5 | 6 | #Create patches in repo 7 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"defaultFeatures\",\"data\":{\"features\":{\"privateRepository\":0}}}" http://localhost:1337/api/v1/repository/github/patch/defaultFeatures 8 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"microFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"micro\"}},\"data\":{\"features\":{\"privateRepository\":5}}}" http://localhost:1337/api/v1/repository/github/patch/microFeatures 9 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"smallFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"small\"}},\"data\":{\"features\":{\"privateRepository\":10}}}" http://localhost:1337/api/v1/repository/github/patch/smallFeatures 10 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"mediumFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"medium\"}},\"data\":{\"features\":{\"privateRepository\":20}}}" http://localhost:1337/api/v1/repository/github/patch/mediumFeatures 11 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"largeFeatures\",\"rule\":{\"name\":\"StringMatchesRule\",\"arguments\":{\"jsonPath\":\"$.user.plan\",\"matches\":\"large\"}},\"data\":{\"features\":{\"privateRepository\":50}}}" http://localhost:1337/api/v1/repository/github/patch/largeFeatures 12 | curl -X POST -H "Content-Type: application/json" -d "{\"id\":\"testingFeatures\",\"data\":{\"features\":{\"privateRepository\":1000}}}" http://localhost:1337/api/v1/repository/github/patch/testingFeatures 13 | 14 | #Modify repo to define branches 15 | curl -X PUT -H "Content-Type: application/json" -d "{\"id\":\"github\",\"branches\":{\"master\":{\"patches\":[\"defaultFeatures\"]},\"qa\":{\"patches\":[\"branch:master\",\"testingFeatures\"]},\"prod\":{\"patches\":[\"branch:master\",\"microFeatures\",\"smallFeatures\",\"mediumFeatures\",\"largeFeatures\"]}}}" http://localhost:1337/api/v1/repository/github 16 | 17 | #Use various repo branches with different contexts 18 | curl -X POST -H "Content-Type: application/json" -d "{\"user\":{\"plan\":\"free\"},\"data\":{}}" http://localhost:1337/api/v1/repository/github/branch/qa/context 19 | curl -X POST -H "Content-Type: application/json" -d "{\"user\":{\"plan\":\"free\"},\"data\":{}}" http://localhost:1337/api/v1/repository/github/branch/prod/context 20 | curl -X POST -H "Content-Type: application/json" -d "{\"user\":{\"plan\":\"medium\"},\"data\":{}}" http://localhost:1337/api/v1/repository/github/branch/prod/context -------------------------------------------------------------------------------- /tests/patchTests.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var Patch = require('../models/Patch'); 3 | var rules = require('../models/rules'); 4 | 5 | describe('Patch', function () { 6 | it('constructs itself as expected', function () { 7 | var patch = new Patch(); 8 | assert.equal(patch.id, null, "id is supposed to be null"); 9 | assert.equal(patch.rule, null, "rule is supposed to be null"); 10 | assert.equal(patch.applyAtJsonPath, '$.data', "default applyAtJsonPath is unexpected"); 11 | assert.ok(patch.data != null && typeof patch.data === 'object', "data should be an object"); 12 | }); 13 | 14 | it('applies a patch', function (done) { 15 | var level1 = new Patch(); 16 | level1.data = { 17 | 'features': { 18 | 'a': false 19 | } 20 | }; 21 | level1.rule = new rules.AlwaysOnRule(); 22 | 23 | var context = { 24 | 'data': { 25 | 'features': { 26 | 'a': true, 27 | 'b': false 28 | } 29 | } 30 | }; 31 | 32 | level1 33 | .apply(context) 34 | .then(function (patchedContext) { 35 | var expectedData = { 36 | 'features': { 37 | 'a': false, 38 | 'b': false 39 | } 40 | }; 41 | assert.deepEqual(context.data, expectedData, 'Unexpected patch/merge result'); 42 | done(); 43 | }) 44 | .catch(done); 45 | }); 46 | 47 | it('shouldApply works', function (done) { 48 | var level1 = new Patch(); 49 | level1.rule = new rules.AlwaysOnRule(); 50 | 51 | level1 52 | .shouldApply({}) 53 | .then(function (shouldApply) { 54 | assert.ok(shouldApply, 'Should apply should have been true'); 55 | done(); 56 | }) 57 | .catch(done); 58 | }); 59 | 60 | it('shouldApply works negative', function (done) { 61 | var level1 = new Patch(); 62 | level1.rule = new rules.AlwaysOffRule(); 63 | 64 | level1 65 | .shouldApply({}) 66 | .then(function (shouldApply) { 67 | assert.ok(!shouldApply, 'Should apply should have been false'); 68 | done(); 69 | }) 70 | .catch(done); 71 | }); 72 | 73 | it ('errors on unknown rule name in constructor', function() { 74 | assert.throws(function(){ 75 | new Patch({ 76 | rule: {name:'thisisnotthenameofarule'} 77 | }); 78 | }); 79 | }); 80 | 81 | it ('constructs expected rule in constructor', function() { 82 | var patch = new Patch({ 83 | rule: {name:'AlwaysOnRule'} 84 | }); 85 | assert.equal(patch.rule.name, 'AlwaysOnRule'); 86 | var AlwaysOnRule = require('../models/rules/AlwaysOnRule'); 87 | assert.ok(patch.rule instanceof AlwaysOnRule); 88 | }); 89 | 90 | }); -------------------------------------------------------------------------------- /tests/rennetServiceTests.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var Patch = require('../models/Patch'); 3 | var RepositoryIndex = require('../models/RepositoryIndex'); 4 | var rules = require('../models/rules'); 5 | var RennetService = require('../services/RennetService'); 6 | 7 | describe('Rennet Service', function () { 8 | it('can resolve a branch with one and only one patch', function (done) { 9 | var service = new RennetService(); 10 | var repositoryId = 'test'; 11 | 12 | //test data 13 | service.patchService.putPatch(repositoryId, new Patch({ 14 | id: 'constants', 15 | data: { 16 | constants: { 17 | 'one': 1, 18 | 'two': 'two', 19 | 'three': null 20 | } 21 | }, 22 | rule: new rules.AlwaysOnRule() 23 | })); 24 | 25 | service.repositoryIndexService.putRepositoryIndex(new RepositoryIndex({ 26 | id: repositoryId, 27 | branches: { 28 | baseline: { 29 | patches : ['constants'] 30 | } 31 | } 32 | })); 33 | 34 | //resolution! 35 | service 36 | .resolveContext(repositoryId, 'baseline', {data:{}}) 37 | .then(function(context){ 38 | assert.ok(context, 'context not truthy'); 39 | assert.equal(context.data.constants.three, null, 'three value not correct'); 40 | assert.equal(context.data.constants.two, 'two', 'two value not correct'); 41 | assert.equal(context.data.constants.one, 1, 'one value not correct'); 42 | done(); 43 | }) 44 | .catch(done); 45 | 46 | }); 47 | 48 | it('can resolve a simple patch', function (done) { 49 | var service = new RennetService(); 50 | var repositoryId = 'test'; 51 | 52 | //test data 53 | service.patchService.putPatch(repositoryId, new Patch({ 54 | id: 'constants', 55 | data: { 56 | constants: { 57 | 'one': 1, 58 | 'two': 'two', 59 | 'three': null 60 | } 61 | }, 62 | rule: new rules.AlwaysOnRule() 63 | })); 64 | 65 | service.patchService.putPatch(repositoryId, new Patch({ 66 | id: 'two_is_three', 67 | data: { 68 | constants: { 69 | 'two': 'three' 70 | } 71 | }, 72 | rule: new rules.AlwaysOnRule() 73 | })); 74 | 75 | service.repositoryIndexService.putRepositoryIndex(new RepositoryIndex({ 76 | id: repositoryId, 77 | branches: { 78 | baseline: { 79 | patches : ['constants'] 80 | }, 81 | patched: { 82 | patches: ['branch:baseline','two_is_three'] 83 | } 84 | } 85 | })); 86 | 87 | //resolution! 88 | service 89 | .resolveContext(repositoryId, 'patched', {data:{}}) 90 | .then(function(context){ 91 | assert.ok(context, 'context not truthy'); 92 | assert.equal(context.data.constants.three, null, 'three value not correct'); 93 | assert.equal(context.data.constants.two, 'three', 'two value not correct'); 94 | assert.equal(context.data.constants.one, 1, 'one value not correct'); 95 | done(); 96 | }) 97 | .catch(done); 98 | 99 | }); 100 | 101 | it('likes nesting', function (done) { 102 | var service = new RennetService(); 103 | var repositoryId = 'test'; 104 | 105 | //test data 106 | service.patchService.putPatch(repositoryId, new Patch({ 107 | id: 'constants', 108 | data: { 109 | constants: { 110 | 'one': 1, 111 | 'two': 'two', 112 | 'three': null 113 | } 114 | }, 115 | rule: new rules.AlwaysOnRule() 116 | })); 117 | 118 | service.patchService.putPatch(repositoryId, new Patch({ 119 | id: 'two_is_three', 120 | data: { 121 | constants: { 122 | 'two': 'three' 123 | } 124 | }, 125 | rule: new rules.AlwaysOnRule() 126 | })); 127 | 128 | service.patchService.putPatch(repositoryId, new Patch({ 129 | id: 'three_is_three', 130 | data: { 131 | constants: { 132 | 'three': 'three' 133 | } 134 | }, 135 | rule: new rules.AlwaysOnRule() 136 | })); 137 | 138 | service.patchService.putPatch(repositoryId, new Patch({ 139 | id: 'add_four', 140 | data: { 141 | constants: { 142 | 'four': 'four' 143 | } 144 | }, 145 | rule: new rules.AlwaysOnRule() 146 | })); 147 | 148 | service.patchService.putPatch(repositoryId, new Patch({ 149 | id: 'add_features_table', 150 | data: { 151 | features: { 152 | 'do_it': true, 153 | 'dont_do_it': false 154 | } 155 | }, 156 | rule: new rules.AlwaysOnRule() 157 | })); 158 | 159 | service.repositoryIndexService.putRepositoryIndex(new RepositoryIndex({ 160 | id: repositoryId, 161 | branches: { 162 | baseline: { 163 | patches : ['constants'] 164 | }, 165 | patched: { 166 | patches: ['branch:baseline','two_is_three'] 167 | }, 168 | superPatched: { 169 | patches: ['branch:patched','three_is_three'] 170 | }, 171 | superDuperPatched: { 172 | patches: ['branch:superPatched','add_four', 'add_features_table'] 173 | } 174 | } 175 | })); 176 | 177 | //resolution! 178 | service 179 | .resolveContext(repositoryId, 'superDuperPatched', {data:{}}) 180 | .then(function(context){ 181 | assert.ok(context, 'context not truthy'); 182 | assert.equal(context.data.constants.four, 'four', 'four value not correct'); 183 | assert.equal(context.data.constants.three, 'three', 'three value not correct'); 184 | assert.equal(context.data.constants.two, 'three', 'two value not correct'); 185 | assert.equal(context.data.constants.one, 1, 'one value not correct'); 186 | assert.ok(context.data.features, 'features table not truthy'); 187 | assert.equal(context.data.features.do_it, true, 'features.do_it not true'); 188 | assert.equal(context.data.features.dont_do_it, false, 'features.dont_do_it not false'); 189 | done(); 190 | }) 191 | .catch(done); 192 | }); 193 | 194 | it('errors on overflow', function (done) { 195 | var service = new RennetService(); 196 | var repositoryId = 'test'; 197 | 198 | //test data 199 | service.patchService.putPatch(repositoryId, new Patch({ 200 | id: 'constants', 201 | data: { 202 | constants: { 203 | 'one': 1, 204 | 'two': 'two', 205 | 'three': null 206 | } 207 | }, 208 | rule: new rules.AlwaysOnRule() 209 | })); 210 | 211 | service.repositoryIndexService.putRepositoryIndex(new RepositoryIndex({ 212 | id: repositoryId, 213 | branches: { 214 | baseline: { 215 | patches : ['constants', 'branch:patched'] 216 | }, 217 | patched: { 218 | patches: ['branch:baseline'] 219 | } 220 | } 221 | })); 222 | 223 | //resolution! 224 | service 225 | .resolveContext(repositoryId, 'superDuperPatched', {data:{}}) 226 | .then(function(context){ 227 | done(new Error("Should have received a catch callback")); 228 | }) 229 | .catch(function(error){ 230 | done(); 231 | }); 232 | }); 233 | 234 | it('applies patches in order', function (done) { 235 | var service = new RennetService(); 236 | var repositoryId = 'test'; 237 | 238 | //test data 239 | service.patchService.putPatch(repositoryId, new Patch({ 240 | id: 'constants', 241 | data: { 242 | constants: { 243 | 'one': 1, 244 | 'two': 'two', 245 | 'three': null 246 | } 247 | }, 248 | rule: new rules.AlwaysOnRule() 249 | })); 250 | 251 | service.patchService.putPatch(repositoryId, new Patch({ 252 | id: 'two_is_three', 253 | data: { 254 | constants: { 255 | 'two': 'three' 256 | } 257 | }, 258 | rule: new rules.AlwaysOnRule() 259 | })); 260 | 261 | service.patchService.putPatch(repositoryId, new Patch({ 262 | id: 'two_is_2', 263 | data: { 264 | constants: { 265 | 'two': 2 266 | } 267 | }, 268 | rule: new rules.AlwaysOnRule() 269 | })); 270 | 271 | service.patchService.putPatch(repositoryId, new Patch({ 272 | id: 'two_is_four', 273 | data: { 274 | constants: { 275 | 'two': 'four' 276 | } 277 | }, 278 | rule: new rules.AlwaysOnRule() 279 | })); 280 | 281 | service.repositoryIndexService.putRepositoryIndex(new RepositoryIndex({ 282 | id: repositoryId, 283 | branches: { 284 | baseline: { 285 | patches : ['constants','two_is_three','two_is_four','two_is_2'] 286 | } 287 | } 288 | })); 289 | 290 | //resolution! 291 | service 292 | .resolveContext(repositoryId, 'baseline', {data:{}}) 293 | .then(function(context){ 294 | assert.equal(context.data.constants.two, 2, 'two value not correct'); 295 | done(); 296 | }) 297 | .catch(done); 298 | }); 299 | }); -------------------------------------------------------------------------------- /tests/ruleTests.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var rules = require('../models/rules'); 3 | 4 | describe('AlwaysOnRule', function () { 5 | it('works correctly', function (done) { 6 | var rule = new rules.AlwaysOnRule(); 7 | rule.evaluate({}) 8 | .then(function(on){ 9 | assert.ok(on, 'AlwaysOnRule was not on'); 10 | done(); 11 | }) 12 | .catch(done); 13 | }); 14 | it('works even with null data', function (done) { 15 | var rule = new rules.AlwaysOnRule(); 16 | rule.evaluate(null) 17 | .then(function(on){ 18 | assert.ok(on, 'AlwaysOnRule was not on'); 19 | done(); 20 | }) 21 | .catch(done); 22 | }); 23 | }); 24 | 25 | describe('AlwaysOffRule', function () { 26 | it('works correctly', function (done) { 27 | var rule = new rules.AlwaysOffRule(); 28 | rule.evaluate({}) 29 | .then(function(on){ 30 | assert.ok(!on, 'AlwaysOffRule was not off'); 31 | done(); 32 | }) 33 | .catch(done); 34 | }); 35 | it('works even with null data', function (done) { 36 | var rule = new rules.AlwaysOffRule(); 37 | rule.evaluate(null) 38 | .then(function(on){ 39 | assert.ok(!on, 'AlwaysOffRule was not off'); 40 | done(); 41 | }) 42 | .catch(done); 43 | }); 44 | }); 45 | 46 | describe('StringMatchesRule', function () { 47 | it('works correctly', function (done) { 48 | var rule = new rules.StringMatchesRule(); 49 | rule.arguments.jsonPath = '$.environment'; 50 | rule.arguments.matches = 'live'; 51 | 52 | rule.evaluate({'environment':'live'}) 53 | .then(function(on){ 54 | assert.ok(on, 'StringMatchesRule was not on'); 55 | done(); 56 | }) 57 | .catch(done); 58 | }); 59 | 60 | it('works in the negative', function (done) { 61 | var rule = new rules.StringMatchesRule(); 62 | rule.arguments.jsonPath = '$.environment'; 63 | rule.arguments.matches = 'live'; 64 | 65 | rule.evaluate({'environment':'dev'}) 66 | .then(function(on){ 67 | assert.ok(!on, 'StringMatchesRule was on'); 68 | done(); 69 | }) 70 | .catch(done); 71 | }); 72 | 73 | it('works with multiple results', function (done) { 74 | var rule = new rules.StringMatchesRule(); 75 | rule.arguments.jsonPath = '$..environment'; 76 | rule.arguments.matches = 'live'; 77 | 78 | rule.evaluate({ 79 | 'environment':'live', 80 | 'nested': { 81 | 'environment':'live' 82 | } 83 | }) 84 | .then(function(on){ 85 | assert.ok(on, 'StringMatchesRule was not on'); 86 | done(); 87 | }) 88 | .catch(done); 89 | }); 90 | 91 | it('fails with AND on multiple results', function (done) { 92 | var rule = new rules.StringMatchesRule(); 93 | rule.arguments.jsonPath = '$..environment'; 94 | rule.arguments.matches = 'live'; 95 | 96 | rule.evaluate({ 97 | 'environment':'live', 98 | 'nested': { 99 | 'environment':'dev' 100 | } 101 | }) 102 | .then(function(on){ 103 | assert.ok(!on, 'StringMatchesRule was on'); 104 | done(); 105 | }) 106 | .catch(done); 107 | }); 108 | }); 109 | 110 | describe('ArrayContainsRule', function () { 111 | it('works correctly', function (done) { 112 | var rule = new rules.ArrayContainsRule(); 113 | rule.arguments.jsonPath = '$.environments'; 114 | rule.arguments.matches = 'live'; 115 | 116 | rule.evaluate({'environments':['dev', 'live']}) 117 | .then(function(on){ 118 | assert.ok(on, 'ArrayContainsRule was not on'); 119 | done(); 120 | }) 121 | .catch(done); 122 | }); 123 | 124 | it('works with multiple matches', function (done) { 125 | var rule = new rules.ArrayContainsRule(); 126 | rule.arguments.jsonPath = '$.environments'; 127 | rule.arguments.matches = 'live'; 128 | 129 | rule.evaluate({'environments':['live', 'live', 'dev', 'live']}) 130 | .then(function(on){ 131 | assert.ok(on, 'ArrayContainsRule was not on'); 132 | done(); 133 | }) 134 | .catch(done); 135 | }); 136 | 137 | it('works in the negative', function (done) { 138 | var rule = new rules.ArrayContainsRule(); 139 | rule.arguments.jsonPath = '$.environments'; 140 | rule.arguments.matches = 'live'; 141 | 142 | rule.evaluate({'environments':['qa', 'dev']}) 143 | .then(function(on){ 144 | assert.ok(!on, 'ArrayContainsRule was on'); 145 | done(); 146 | }) 147 | .catch(done); 148 | }); 149 | 150 | it('works in the negative with empty array', function (done) { 151 | var rule = new rules.ArrayContainsRule(); 152 | rule.arguments.jsonPath = '$.environments'; 153 | rule.arguments.matches = 'live'; 154 | 155 | rule.evaluate({'environments':[]}) 156 | .then(function(on){ 157 | assert.ok(!on, 'ArrayContainsRule was on'); 158 | done(); 159 | }) 160 | .catch(done); 161 | }); 162 | 163 | it('works in the negative with null array', function (done) { 164 | var rule = new rules.ArrayContainsRule(); 165 | rule.arguments.jsonPath = '$.environments'; 166 | rule.arguments.matches = 'live'; 167 | 168 | rule.evaluate({'environments':null}) 169 | .then(function(on){ 170 | assert.ok(!on, 'ArrayContainsRule was on'); 171 | done(); 172 | }) 173 | .catch(done); 174 | }); 175 | 176 | it('works in the negative with invalid jsonPath', function (done) { 177 | var rule = new rules.ArrayContainsRule(); 178 | rule.arguments.jsonPath = '$.thisNodeDoesNotExist'; 179 | rule.arguments.matches = 'live'; 180 | 181 | rule.evaluate({'environments':null}) 182 | .then(function(on){ 183 | assert.ok(!on, 'ArrayContainsRule was on'); 184 | done(); 185 | }) 186 | .catch(done); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /views/error.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= message 5 | h2= error.status 6 | pre #{error.stack} -------------------------------------------------------------------------------- /views/index.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= title 5 | div(ng-controller="RennetController") 6 | p Nothing to see here yet... The date is {{value}}. 7 | -------------------------------------------------------------------------------- /views/layout.jade: -------------------------------------------------------------------------------- 1 | mixin ie(condition) 2 | | 5 | 6 | doctype html 7 | html(ng-app="rennetApp", lang="en") 8 | head 9 | title= title 10 | meta(charset="utf-8") 11 | meta(http-equiv="X-UA-Compatible", content="IE=edge") 12 | meta(name="viewport", content="width=device-width, initial-scale=1") 13 | 14 | link(rel='stylesheet', href='/bower_components/bootstrap/dist/css/bootstrap.css') 15 | link(rel='stylesheet', href='/bower_components/bootstrap/dist/css/bootstrap-theme.css') 16 | 17 | +ie('if lt IE 9') 18 | script(src='/bower_components/html5shiv/dist/html5shiv.min.js', type='text/javascript') 19 | script(src='/bower_components/respond/dest/respond.min.js', type='text/javascript') 20 | body 21 | block content 22 | script(src='/bower_components/jquery/dist/jquery.js', type='text/javascript') 23 | script(src='/bower_components/bootstrap/dist/js/bootstrap.js', type='text/javascript') 24 | script(src='/bower_components/angularjs/angular.js', type='text/javascript') 25 | script(src='/js/rennet.js', type='text/javascript') --------------------------------------------------------------------------------