├── .circleci └── config.yml ├── .dockerignore ├── .env ├── .eslintignore ├── .eslintrc ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .sequelizerc ├── DataSyncService.js ├── Dockerfile ├── README.md ├── docker-compose.yml ├── examples ├── memeolist.query.examples.graphql ├── memeolist.query.graphql ├── memeolist.tables.sql └── memeolist.variables.json ├── index.js ├── initial_architecture_flow.png ├── integration_test ├── auth.integration.test.gql.js ├── auth.integration.test.js ├── auth.integration.test.keycloak.js ├── config.integration.test.js ├── config │ ├── auth.complete.inmem.valid.memeo.js │ ├── auth.complete.memeo.schema.only.js │ ├── complete.inmem.valid.memeo.js │ ├── complete.postgres.valid.memeo.js │ ├── complete.valid.memeo.schema.only.js │ ├── keycloak.json │ ├── realm-export.json │ ├── simple.inmem.invalid.bad.schema.syntax.js │ ├── simple.inmem.invalid.notes.no.resolver.for.query.js │ ├── simple.inmem.valid.empty.schema.js │ ├── simple.inmem.valid.notes.js │ └── simple.inmem.valid.resolver.not.in.schema.js ├── datasource.integration.test.base.js ├── datasyncservice.smoke.test.js ├── helper.js ├── inmem.integration.test.js ├── postgres.integration.test.js ├── resolver.publisers.integration.test.js ├── subscriptions.hot.reload.integration.test.js ├── subscriptions.inmem.integration.test.js ├── subscriptions.integration.test.base.js ├── subscriptions.postgres.integration.test.js └── util │ ├── auth.js │ ├── restartableSyncService.js │ └── testApolloClient.js ├── k8s_templates ├── data-sources.json ├── datasync_deployment.yml ├── datasync_route.yml ├── datasync_service.yml ├── postgres_claim.yml ├── postgres_deployment.yml └── postgres_service.yml ├── keycloak ├── docker-compose.yml ├── keycloak.json └── realm-export.json ├── package-lock.json ├── package.json ├── scripts ├── docker_build.sh ├── docker_build_release.sh ├── docker_push.sh ├── docker_push_release.sh ├── sync_models.js └── validateRelease.sh ├── sequelize ├── config │ └── database.js ├── models │ └── index.js └── seeders │ ├── memeolist-example-inmem.js │ ├── memeolist-example-postgres.js │ └── memeolist-example-shared.js └── server ├── apolloServer.js ├── config └── index.js ├── expressApp.js ├── health └── index.js ├── lib ├── pubsubNotifiers │ ├── notifiers │ │ ├── inMemory.js │ │ ├── index.js │ │ └── postgres.js │ ├── pubsubNotifier.js │ └── pubsubNotifier.test.js ├── schemaListeners │ ├── listeners │ │ ├── index.js │ │ └── postgres.js │ ├── schemaListenerCreator.js │ └── schemaListenerCreator.test.js └── util │ ├── internalServerError.js │ ├── internalServerError.test.js │ ├── logger.js │ └── logger.test.js ├── metrics └── index.js ├── security ├── SecurityService.js └── services │ ├── index.js │ └── keycloak │ ├── AuthContextProvider.js │ ├── AuthContextProvider.test.js │ ├── KeycloakSecurityService.js │ └── schemaDirectives │ ├── hasRole.js │ ├── hasRole.test.js │ └── index.js └── server.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | unit_test: 4 | docker: 5 | # Node 8 LTS 6 | - image: circleci/node:carbon 7 | steps: 8 | - checkout 9 | - run: npm install 10 | - run: npm run lint 11 | - run: npm run test 12 | 13 | integration_test: 14 | docker: 15 | # Node 8 LTS 16 | - image: circleci/node:carbon 17 | # configuration database 18 | - image: circleci/postgres:9.6.2-alpine 19 | name: config_postgres 20 | environment: 21 | POSTGRES_USER: postgresql 22 | POSTGRES_PASSWORD: postgres 23 | POSTGRES_DB: aerogear_data_sync_db 24 | # memeolist database 25 | - image: circleci/postgres:9.6.2-alpine 26 | name: memeolist_postgres 27 | environment: 28 | POSTGRES_USER: postgresql 29 | POSTGRES_PASSWORD: postgres 30 | POSTGRES_DB: memeolist_db 31 | # keycloak 32 | - image: jboss/keycloak:3.4.3.Final 33 | name: keycloak_instance 34 | environment: 35 | KEYCLOAK_USER: admin 36 | KEYCLOAK_PASSWORD: admin 37 | DB_VENDOR: h2 38 | steps: 39 | - checkout 40 | - run: 41 | name: Wait for configuration database to start up 42 | command: dockerize -wait tcp://config_postgres:5432 -timeout 120s 43 | - run: 44 | name: Wait for memeolist database to start up 45 | command: dockerize -wait tcp://memeolist_postgres:5432 -timeout 120s 46 | - run: 47 | name: Wait for keycloak instance to start up 48 | command: dockerize -wait tcp://keycloak_instance:8080 -timeout 120s 49 | - run: npm install 50 | - run: 51 | command: npm run db:init 52 | environment: 53 | POSTGRES_HOST: 'config_postgres' 54 | POSTGRES_PORT: '5432' 55 | - run: npm install tap-xunit --dev 56 | - run: mkdir -p ~/reports 57 | - run: 58 | command: npm run test:integration 59 | environment: 60 | POSTGRES_HOST: 'config_postgres' 61 | POSTGRES_PORT: '5432' 62 | MEMEOLIST_DB_HOST: 'memeolist_postgres' 63 | MEMEOLIST_DB_PORT: '5432' 64 | KEYCLOAK_HOST: 'keycloak_instance' 65 | KEYCLOAK_PORT: '8080' 66 | 67 | test_coverage: 68 | docker: 69 | # Node 8 LTS 70 | - image: circleci/node:carbon 71 | # configuration database 72 | - image: circleci/postgres:9.6.2-alpine 73 | name: config_postgres 74 | environment: 75 | POSTGRES_USER: postgresql 76 | POSTGRES_PASSWORD: postgres 77 | POSTGRES_DB: aerogear_data_sync_db 78 | # memeolist database 79 | - image: circleci/postgres:9.6.2-alpine 80 | name: memeolist_postgres 81 | environment: 82 | POSTGRES_USER: postgresql 83 | POSTGRES_PASSWORD: postgres 84 | POSTGRES_DB: memeolist_db 85 | # keycloak 86 | - image: jboss/keycloak:3.4.3.Final 87 | name: keycloak_instance 88 | environment: 89 | KEYCLOAK_USER: admin 90 | KEYCLOAK_PASSWORD: admin 91 | DB_VENDOR: h2 92 | steps: 93 | - checkout 94 | - run: 95 | name: Wait for configuration database to start up 96 | command: dockerize -wait tcp://config_postgres:5432 -timeout 120s 97 | - run: 98 | name: Wait for memeolist database to start up 99 | command: dockerize -wait tcp://memeolist_postgres:5432 -timeout 120s 100 | - run: 101 | name: Wait for keycloak instance to start up 102 | command: dockerize -wait tcp://keycloak_instance:8080 -timeout 120s 103 | - run: npm install 104 | - run: 105 | command: npm run db:init 106 | environment: 107 | POSTGRES_HOST: 'config_postgres' 108 | POSTGRES_PORT: '5432' 109 | - run: 110 | # CircleCI project has to have COVERALLS_REPO_TOKEN env set 111 | # in order to make coveralls working 112 | command: npm run test:cover 113 | environment: 114 | POSTGRES_HOST: 'config_postgres' 115 | POSTGRES_PORT: '5432' 116 | MEMEOLIST_DB_HOST: 'memeolist_postgres' 117 | MEMEOLIST_DB_PORT: '5432' 118 | KEYCLOAK_HOST: 'keycloak_instance' 119 | KEYCLOAK_PORT: '8080' 120 | 121 | docker_push_master: 122 | docker: 123 | # Node 8 LTS 124 | - image: circleci/node:carbon 125 | steps: 126 | - checkout 127 | - setup_remote_docker 128 | - run: npm run docker:build 129 | - run: npm run docker:push 130 | 131 | docker_push_release: 132 | docker: 133 | # Node 8 LTS 134 | - image: circleci/node:carbon 135 | steps: 136 | - checkout 137 | - setup_remote_docker 138 | - run: TAG=$CIRCLE_TAG npm run release:validate 139 | - run: npm run docker:build:release 140 | - run: npm run docker:push:release 141 | 142 | workflows: 143 | version: 2 144 | build_and_push: 145 | jobs: 146 | - unit_test: 147 | filters: 148 | tags: 149 | only: /.*/ 150 | - integration_test: 151 | filters: 152 | tags: 153 | only: /.*/ 154 | - test_coverage: 155 | filters: 156 | tags: 157 | only: /.*/ 158 | - docker_push_master: 159 | requires: 160 | - unit_test 161 | - integration_test 162 | filters: 163 | branches: 164 | only: 165 | - master 166 | - docker_push_release: 167 | requires: 168 | - unit_test 169 | - integration_test 170 | filters: 171 | tags: 172 | only: /.*/ # allow anything because tag syntax is validated as part of validate-release.sh 173 | branches: 174 | ignore: /.*/ 175 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | test 4 | CONTRIBUTING.md 5 | Jenkinsfile 6 | LICENSE 7 | README.md 8 | *.png 9 | nodemon.json 10 | k8s_templates 11 | .vscode 12 | .circleci 13 | .env 14 | keycloak 15 | integration_test 16 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DATABASE= 2 | POSTGRES_USERNAME= 3 | POSTGRES_PASSWORD= 4 | POSTGRES_HOST= 5 | POSTGRES_PORT= 6 | 7 | QUERY_DEPTH_LIMIT= 8 | COMPLEXITY_LIMIT= 9 | 10 | LOG_LEVEL= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "error" 4 | }, 5 | "extends": "standard" 6 | } -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @aliok @darahayes 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at #aerogear channel on freenode IRC or via aerogear@googlegroups.com, see [AeroGear forum](https://groups.google.com/forum/#!forum/aerogear). The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | For more information about the AeroGear community and project , visit [our website](https://aerogear.org/community/). 39 | 40 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 41 | 42 | ## Attribution 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 45 | 46 | [homepage]: http://contributor-covenant.org 47 | [version]: http://contributor-covenant.org/version/1/4/ 48 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Thank you for your interest in contributing to the AeroGear project. We want 4 | keep this process as easy as possible so we've outlined a few guidelines below. 5 | For more information about the AeroGear community and project , visit 6 | [our website](https://aerogear.org/community/). 7 | 8 | ## Asking for help 9 | 10 | Whether you're contributing a new feature or bug fix, or simply submitting a ticket, the AeroGear team is available for technical advice or feedback. 11 | You can reach us at #aerogear on [Freenode IRC](https://freenode.net/) or join the [mailing list](https://groups.google.com/forum/#!forum/aerogear) 12 | -- both are actively monitored. 13 | 14 | ## Getting started 15 | 16 | * Make sure you have a [JIRA account](https://issues.jboss.org) 17 | * Make sure you have a [GitHub account](https://github.com/signup/free) 18 | * Submit a ticket for your issue to the 19 | [AeroGear project]("https://issues.jboss.org/projects/AEROGEAR), assuming one does 20 | not already exist. 21 | * Clearly describe the issue including steps to reproduce when it is a bug. 22 | * Make sure you fill in the earliest version that you know has the issue. 23 | * Fork the repository on GitHub. 24 | 25 | ## Making changes 26 | 27 | * Create a topic branch from where you want to base your work. 28 | * This is usually the master branch. 29 | * To quickly create a topic branch based on master; `git checkout -b 30 | master`. By convention we typically include the JIRA issue 31 | key in the branch name, e.g. `AEROGEAR-1234-my-feature`. 32 | * Please avoid working directly on the `master` branch. 33 | * Make commits of logical units. 34 | * Prepend your commit messages with a JIRA ticket number, e.g. "AEROGEAR-1234: Fix 35 | spelling mistake in README." 36 | * Follow the coding style in use. 37 | * Check for unnecessary whitespace with `git diff --check` before committing. 38 | * Make sure you have added the necessary tests for your changes. 39 | * Run _all_ the tests to assure nothing else was accidentally broken. 40 | 41 | ## Submitting changes 42 | 43 | * Push your changes to a topic branch in your fork of the repository. 44 | * Submit a pull request to the repository in the [AeroGear GitHub organization] 45 | (https://github.com/aerogear) and choose branch you want to patch 46 | (usually master). 47 | * Advanced users may want to install the [GitHub CLI](https://hub.github.com/) 48 | and use the `hub pull-request` command. 49 | * Update your JIRA ticket to mark that you have submitted code and are ready 50 | for it to be reviewed (Status: Dev Complete). 51 | * Include a link to the pull request in the ticket. 52 | * Add detail about the change to the pull request including screenshots 53 | if the change affects the UI. 54 | 55 | ## Reviewing changes 56 | 57 | * After submitting a pull request, one of AeroGear team members will review it. 58 | * Changes may be requested to conform to our style guide and internal 59 | requirements. 60 | * When the changes are approved and all tests are passing, a AeroGear team 61 | member will merge them. 62 | * Note: if you have write access to the repository, do not directly merge pull 63 | requests. Let another team member review your pull request and approve it. 64 | 65 | # Additional Resources 66 | 67 | * [General GitHub documentation](http://help.github.com/) 68 | * [GitHub pull request documentation](https://help.github.com/articles/about-pull-requests/) 69 | * [Read the Issue Guidelines by @necolas](https://github.com/necolas/issue-guidelines/blob/master/CONTRIBUTING.md) for more details 70 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | Add a brief and meaningful description. 3 | 4 | ## Expected Behavior 5 | Describe the expected behaviour. 6 | 7 | ## Actual Behavior 8 | Describe the current/actual behaviour. 9 | 10 | ## Environment 11 | 12 | * Operating system: 13 | * OpenShift versions: 14 | * Project Versions: 15 | 16 | ## Steps to reproduce 17 | Describe all steps and pre-requirements which are required to be performed in order to reproduce this scenario. ( E.g 1. Action, 2. Action ... ) 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Motivation 2 | Add references to relevant tickets or a short description about what motivated you do it. (E.g JIRA: https://issues.jboss.org/browse/AEROGEAR-{} AND/OR ISSUE: https://github.com/aerogear/standards/issues/{}) 3 | 4 | ## What 5 | Add an short answer for: What was done in this PR? (E.g Don't allow users has access to the feature X.) 6 | 7 | ## Why 8 | Add an short answer for: Why it was done? (E.g The feature X was deprecated.) 9 | 10 | ## How 11 | Add an short answer for: How it was done? (E.g By removing this feature from ... OR By removing just the button but not its implementation ... ) 12 | 13 | ## Verification Steps 14 | Add the steps required to check this change. Following an example. 15 | 16 | 1. Go to `XX >> YY >> SS` 17 | 2. Create a new item `N` with the info `X` 18 | 3. Try to edit this item 19 | 4. Check if in the left menu the feature X is not so long present. 20 | 21 | ## Checklist: 22 | 23 | - [ ] Code has been tested locally by PR requester 24 | - [ ] Changes have been successfully verified by another team member 25 | 26 | ## Progress 27 | 28 | - [x] Finished task 29 | - [ ] TODO 30 | 31 | ## Additional Notes 32 | 33 | PS.: Add images and/or .gifs to illustrate what was changed if this pull request modifies the appearance/output of something presented to the users. 34 | 35 | 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode/ 3 | .idea 4 | coverage 5 | .nyc_output 6 | coverage.lcov 7 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | 'config': path.resolve('sequelize/config', 'database.js'), 5 | 'models-path': path.resolve('sequelize', 'models'), 6 | 'seeders-path': path.resolve('sequelize', 'seeders'), 7 | 'migrations-path': path.resolve('sequelize', 'migrations') 8 | } -------------------------------------------------------------------------------- /DataSyncService.js: -------------------------------------------------------------------------------- 1 | const { Core } = require('@aerogear/data-sync-gql-core') 2 | const { makeExecutableSchema } = require('graphql-tools') 3 | const DataSyncServer = require('./server/server') 4 | const { log } = require('./server/lib/util/logger') 5 | const PubSub = require('./server/lib/pubsubNotifiers/pubsubNotifier') 6 | 7 | class DataSyncService { 8 | constructor (config) { 9 | this.config = config 10 | this.port = this.config.server.port 11 | this.log = log 12 | 13 | this.app = null 14 | this.pubsub = null 15 | } 16 | 17 | async initialize () { 18 | let { pubsubConfig } = this.config 19 | this.pubsub = PubSub(pubsubConfig) 20 | 21 | this.core = new Core(this.config.postgresConfig, makeExecutableSchema) 22 | 23 | this.models = await this.core.getModels() 24 | this.models.sync({ logging: false }) 25 | 26 | this.app = new DataSyncServer(this.config, this.models, this.pubsub, this.core) 27 | await this.app.initialize() 28 | } 29 | 30 | async start () { 31 | await this.app.server.startListening(this.port) 32 | this.log.info(`Server is now running on http://localhost:${this.port}`) 33 | } 34 | 35 | async gracefulShutdown (signal) { 36 | try { 37 | this.log.info(`${signal} received. Closing connections, stopping server`) 38 | await this.app.cleanup 39 | this.log.info('Shutting down') 40 | } catch (ex) { 41 | this.log.error('Error during graceful shutdown') 42 | this.log.error(ex) 43 | } finally { 44 | process.exit(0) 45 | } 46 | } 47 | } 48 | 49 | module.exports = DataSyncService 50 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM centos/nodejs-8-centos7:latest 2 | 3 | EXPOSE 8000 4 | 5 | USER root 6 | 7 | COPY . ./ 8 | 9 | USER default 10 | 11 | RUN scl enable rh-nodejs8 "npm install --production" 12 | 13 | CMD ["npm", "start"] 14 | 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AeroGear Sync Server (DEPRECATED) 2 | 3 | [![circle-ci](https://img.shields.io/circleci/project/github/aerogear/data-sync-server/master.svg)](https://circleci.com/gh/aerogear/data-sync-server) 4 | [![Coverage Status](https://img.shields.io/coveralls/github/aerogear/data-sync-server.svg)](https://codecov.io/gh/aerogear/data-sync-server) 5 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 6 | [![Docker Hub](https://img.shields.io/docker/automated/jrottenberg/ffmpeg.svg)](https://hub.docker.com/r/aerogear/data-sync-server/) 7 | [![Docker Stars](https://img.shields.io/docker/stars/aerogear/data-sync-server.svg)](https://registry.hub.docker.com/v2/repositories/aerogear/data-sync-server/stars/count/) 8 | [![Docker pulls](https://img.shields.io/docker/pulls/aerogear/data-sync-server.svg)](https://registry.hub.docker.com/v2/repositories/aerogear/data-sync-server/) 9 | [![License](https://img.shields.io/:license-Apache2-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0) 10 | 11 | GraphQL based data sync server for mobile, with backend integration capabilities 12 | 13 | *Project moved to [apollo-voyager-server](https://github.com/aerogear/apollo-voyager-server)* repository 14 | 15 | ## Table of Contents 16 | 17 | * [Architecture](#architecture) 18 | * [Configuration](#configuration) 19 | * [Getting Started](#getting-started) 20 | * [Postgres](#postgres) 21 | + [Inspecting](#inspecting) 22 | + [Cleanup Postgres](#cleanup-postgres) 23 | * [Tests](#tests) 24 | + [Running Unit Tests](#running-unit-tests) 25 | + [Running Integration tests:](#running-integration-tests-) 26 | + [Running all tests with CircleCi CLI](#running-all-tests-with-circleci-cli) 27 | + [Running Individual Tests](#running-individual-tests) 28 | + [Debugging Individual Tests](#debugging-individual-tests) 29 | * [Memeolist](#memeolist) 30 | + [What's Memeolist?](#what-s-memeolist-) 31 | + [In memory](#in-memory) 32 | + [Postgres](#postgres-1) 33 | 34 | ## Architecture 35 | 36 | The baseline architecture is shown below: 37 | 38 | ![Initial Data Sync Architecture](./initial_architecture_flow.png) 39 | 40 | 1. The [GraphQL](http://graphql.github.io/) Data Schema, Resolvers etc.. are defined in the [Data Sync UI](https://github.com/aerogear/data-sync-ui) 41 | 1. All this config is deployed to the Data Sync GraphQL Server 42 | 1. The developer generates typed Models for use in their App based on the schema defined 43 | 1. The developer executes queries, mutations & subsdcriptions in their App, which uses the [Apollo GraphQL client](https://www.apollographql.com/client/) to talk to the server. [The Apollo GraphQL Client](https://www.apollographql.com/client/) is auto configured by the AeroGear SDK e.g. it knows what the Data Sync GraphQL Server url is. 44 | 1. The Data Sync GraphQL Server executes the corresponding resolvers for queries, mutations & subscriptions. 45 | 1. Configured Authentication & Autohorizatin checks are applied 46 | 1. Logging & Metrics data is gathered from the Server & connected Clients 47 | 48 | ## Getting Started 49 | 50 | 1. Install Dependencies 51 | 52 | ```shell 53 | npm install 54 | ``` 55 | 56 | 1. Start and initialize the database 57 | 58 | Use docker compose to start the database(s). 59 | 60 | ```shell 61 | docker-compose -p sync up 62 | ``` 63 | 64 | There are 2 Postgres instances defined in docker-compose configuration: 65 | 66 | 1. For storing the configuration of the sync server itself 67 | 1. For storing the [Memeolist](#whats-memeolist) data. 68 | 69 | Since docker-compose is only used with development, starting up the Postgres instance for [Memeolist](#whats-memeolist) will not cause any harm. 70 | 71 | 1. Initialize the database. 72 | 73 | **WARNING: These are destructive actions.** they drop and recreate the tables every time. 74 | 75 | No sample schema/resolvers 76 | 77 | ```shell 78 | npm run db:init 79 | ``` 80 | 81 | The Commands below are useful for **local development** which seed the database with config and tables 82 | for [Memeolist](#whats-memeolist) sample application. 83 | 84 | Sample schema/resolvers for memeolist - in-memory data source 85 | ```shell 86 | npm run db:init:memeo:inmem 87 | ``` 88 | 89 | Sample schema/resolvers for memeolist - Postgres data source 90 | ```shell 91 | npm run db:init:memeo:postgres 92 | ``` 93 | 94 | 1. Start the Server 95 | 96 | ```shell 97 | npm run dev 98 | ``` 99 | 100 | 1. Go to http://localhost:8000/graphql for an interactive query browser. 101 | 102 | The **graphql endpoint** is at `/graphql`. 103 | The **subscriptions endpoint** is also at `/graphql`. 104 | 105 | ## Postgres 106 | 107 | ### Inspecting 108 | 109 | ```shell 110 | npm run db:shell 111 | ``` 112 | 113 | ### Cleanup Postgres 114 | 115 | The Postgres container started by `docker-compose` can be stopped with `Ctrl + C`. To remove it fully: 116 | 117 | ```shell 118 | docker-compose -p sync rm 119 | 120 | Going to remove ... 121 | Are you sure? [yN] y 122 | ``` 123 | 124 | ## Tests 125 | 126 | ### Running Unit Tests 127 | 128 | ```shell 129 | npm run test 130 | ``` 131 | 132 | ### Running Integration Tests 133 | 134 | Start the database first: 135 | 136 | ```shell 137 | docker-compose -p sync up 138 | ``` 139 | 140 | In another session, run the tests: 141 | 142 | ```shell 143 | npm run test:integration 144 | ``` 145 | 146 | ### Running Individual Tests 147 | 148 | Assuming you have `npm@5.2.0` or greater you can do the following: 149 | 150 | ```shell 151 | npx ava /path/to/test.js 152 | ``` 153 | 154 | `npx` will ensure the correct version of ava (specified in package.json) is used. 155 | 156 | ### Debugging Individual Tests 157 | 158 | The easiest way to debug tests is using [Chrome DevTools](https://developers.google.com/web/tools/chrome-devtools/). Use [inspect-process](https://npm.im/inspect-process) to easily launch a debugging session with Chrome DevTools. 159 | 160 | ```shell 161 | npm install -g inspect-process 162 | ``` 163 | 164 | 1. In chrome go to [`chrome://inspect`](chrome://inspect/) 165 | 1. Click on 'Open dedicated DevTools for Node.' This will open a new DevTools window. 166 | 1. Click on 'add folder to workspace' and use the wizard to open this project. 167 | 1. Go to the appropriate test file (or code that's being tested) and set a breakpoint 168 | 1. Now run the individual test as follows: 169 | 170 | ```shell 171 | inspect node_modules/ava/profile.js some/test/file.js 172 | ``` 173 | 174 | ### Running all tests with CircleCi CLI 175 | 176 | 1. Install [CircleCi CLI](https://circleci.com/docs/2.0/local-cli/) 177 | 1. Execute these commands locally: 178 | 179 | ```shell 180 | # CircleCi CLI doesn't support workflows yet 181 | circleci build --job unit_test 182 | circleci build --job integration_test 183 | ``` 184 | 185 | ## Configuration 186 | 187 | This server requires a bunch of environment variables to be set. If they're not set, defaults for development will be used. 188 | 189 | * `AUDIT_LOGGING`: : If true, audit logs of resolver operations will be logged to stdout. Defaults to true. 190 | * `POSTGRES_DATABASE`: Name of the Postgres database. Defaults to `aerogear_data_sync_db` 191 | * `POSTGRES_USERNAME`: Username to use when connecting Postgres. Defaults to `postgresql` 192 | * `POSTGRES_PASSWORD`: Password to use when connecting Postgres. Defaults to `postgres` 193 | * `POSTGRES_HOST`: Postgres host name. Defaults to `127.0.0.1` 194 | * `POSTGRES_PORT`: Postgres port. Defaults to `5432` 195 | * `SCHEMA_LISTENER_CONFIG`: Configuration of the config listening mechanism. Defaults to listening to a Postgres channel. 196 | Value of this environment variable must be a base64 encoded JSON. See below for an example. 197 | 198 | ```shell 199 | $ echo ' 200 | { 201 | "type": "postgres", 202 | "config": { 203 | "channel": "aerogear-data-sync-config", 204 | "database": "aerogear_data_sync_db", 205 | "user": "postgresql", 206 | "password": "postgres", 207 | "host": "127.0.0.1", 208 | "port": "5432" 209 | } 210 | } 211 | ' | base64 --wrap=0 212 | 213 | > CnsKICAidHlwZSI6ICJwb3N0Z3JlcyIsCiAgImNvbmZpZyI6IHsKICAgICJjaGFubmVsIjogImFlcm9nZWFyLWRhdGEtc3luYy1jb25maWciLAogICAgImRhdGFiYXNlIjogImFlcm9nZWFyX2RhdGFfc3luY19kYiIsCiAgICAidXNlcm5hbWUiOiAicG9zdGdyZXNxbCIsCiAgICAicGFzc3dvcmQiOiAicG9zdGdyZXMiLAogICAgImhvc3QiOiAiMTI3LjAuMC4xIiwKICAgICJwb3J0IjogIjU0MzIiIAogIH0gCn0KCg== 214 | ``` 215 | Currently only Postgres channel listening is supported. 216 | 217 | The DevTools window should automatically connect to the debugging session and execution should pause if some breakpoints are set. 218 | 219 | ## Memeolist 220 | 221 | ### What's Memeolist? 222 | 223 | Memeolist is an application where the AeroGear community can test AeroGear Mobile Sync Services and SDKs and is [based on the dogfood proposal](https://github.com/aerogear/proposals/blob/master/dogfood.md) 224 | 225 | ### In memory 226 | 227 | To start the application with MemeoList schema and queries with an in-memory data source, run the following commands: 228 | 229 | ```shell 230 | docker-compose -p sync up 231 | npm run db:init:memeo:inmem 232 | npm run dev:memeo 233 | ``` 234 | 235 | ### Postgres 236 | 237 | To start the application with MemeoList schema and queries with a Postgres source, run the following commands: 238 | 239 | ```shell 240 | docker-compose -p sync up 241 | npm run db:init:memeo:postgres 242 | npm run dev:memeo 243 | ``` 244 | 245 | ### Authentication and Authorization 246 | 247 | By default, the server starts without any authentication and authorization mechanism configured. 248 | Please follow the documentation below to see how to enable support for this feature. 249 | 250 | ## Using Keycloak during Local Development 251 | 252 | To use Keycloak for authorisation, set the environment variable `KEYCLOAK_CONFIG_FILE` to point to a config file. An example can be seen at [./keycloak/keycloak.json](./keycloak/keycloak.json). 253 | To use Keycloak with Sync, complete the steps above in the [Getting Started](#getting-started) section to create and initialise the database, then start the application by running: 254 | 255 | ```shell 256 | npm run dev 257 | ``` 258 | 259 | If you do not have any running keycloak instance, it can be run with docker-compose. Use the separate docker compose file located at the [./keycloak/](./keycloak/) folder. 260 | 261 | ``` 262 | npm run compose:sync:keycloak 263 | ``` 264 | 265 | Once the application is started, visit http://localhost:8000/graphql. You should be redirected to the login for your realm. You can log in here with the example credentials. 266 | 267 | Once logged in and you are redirected to the Graphql playground you will need to (for the time being) manually attach the Bearer token used by Keycloak to each request. To get this token, visit http://localhost:8000/token and put this whole string in the HTTP HEADERS section of Graphql Playground. 268 | 269 | Each request should now be autorised via Keycloak. To logout visit http://localhost:8000/logout. 270 | 271 | 272 | ### Keycloak SSO support 273 | 274 | Keycloak integration is supported by providing a location to the keycloak configuration file: 275 | 276 | ``` 277 | KEYCLOAK_CONFIG_FILE=keycloak/keycloak.json 278 | ``` 279 | 280 | You can also execute `npm run dev:keymemeo` to run the server preconfigured with an example keycloak server. 281 | 282 | The Memeolist example application requires a keycloak realm to be configured. 283 | See [Keycloak realm](./keycloak) configuration for more details. 284 | 285 | Currently this file points to a demo Keycloak instance hosted at https://keycloak.security.feedhenry.org. If you wish, you can also use the realm-export file, mentioned above, to create a realm on your own Keycloak instance. 286 | 287 | The credentials currently available for use on this realm are: 288 | 289 | ``` 290 | u: admin/voter 291 | p: 123 292 | ``` 293 | 294 | Currently, the roles available in the demo instance for use are 'admin' and 'voter'. 295 | 296 | See the [Using Keycloak for local development](#using-keycloak-for-local-development) above for details about how to use authorisation once it is configured. 297 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres: 5 | image: postgres:9.6 6 | ports: 7 | - "5432:5432" 8 | environment: 9 | POSTGRES_PASSWORD: postgres 10 | POSTGRES_USER: postgresql 11 | POSTGRES_DB: aerogear_data_sync_db 12 | 13 | postgres_memeo: 14 | image: postgres:9.6 15 | ports: 16 | - "15432:5432" 17 | environment: 18 | POSTGRES_PASSWORD: postgres 19 | POSTGRES_USER: postgresql 20 | POSTGRES_DB: memeolist_db 21 | volumes: 22 | - ./examples:/tmp/examples 23 | -------------------------------------------------------------------------------- /examples/memeolist.query.examples.graphql: -------------------------------------------------------------------------------- 1 | query allMemes { 2 | allMemes { 3 | id 4 | photourl 5 | likes 6 | owner { 7 | id 8 | email 9 | displayname 10 | pictureurl 11 | } 12 | } 13 | } 14 | 15 | mutation createMeme { 16 | createMeme(owner: "1", photourl: "https://i.imgur.com/W4dhL0b.jpg") { 17 | id 18 | photourl 19 | likes 20 | owner { 21 | id 22 | email 23 | displayname 24 | pictureurl 25 | } 26 | } 27 | } 28 | 29 | subscription memeAdded { 30 | memeAdded { 31 | id 32 | photourl 33 | likes 34 | owner { 35 | id 36 | displayname 37 | email 38 | pictureurl 39 | } 40 | } 41 | } 42 | 43 | query comments { 44 | comments(memeid: ID!) { 45 | id 46 | comment 47 | owner { 48 | id 49 | displayname 50 | email 51 | pictureurl 52 | } 53 | } 54 | } 55 | 56 | mutation postComment { 57 | postComment(memeid: "Fha32ppqLNWEfX3b", comment: "Like it", owner: "1") { 58 | id 59 | comment 60 | owner { 61 | id 62 | displayname 63 | email 64 | pictureurl 65 | } 66 | } 67 | } 68 | 69 | mutation likeMeme { 70 | likeMeme(id: "Fha32ppqLNWEfX3b") 71 | } 72 | 73 | ## Profile section 74 | mutation createProfile { 75 | createProfile(email: "wtr1@redhit.com", displayname: "wojtek", pictureurl: "https://tinyurl.com/avataraerogear") { 76 | id 77 | email 78 | displayname 79 | pictureurl 80 | } 81 | } 82 | 83 | query profile { 84 | profile(email: "wtr1@redhit.com") { 85 | id 86 | email 87 | displayname 88 | pictureurl 89 | } 90 | } 91 | 92 | subscription memeAdded { 93 | memeAdded { 94 | id 95 | photourl 96 | likes 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /examples/memeolist.query.graphql: -------------------------------------------------------------------------------- 1 | query allMemes { 2 | allMemes { 3 | id 4 | photourl 5 | likes 6 | owner { 7 | id 8 | displayname 9 | email 10 | pictureurl 11 | } 12 | } 13 | } 14 | 15 | mutation createMeme($owner: ID!, $photourl: String!) { 16 | createMeme(owner: $owner, photourl: $photourl) { 17 | id 18 | photourl 19 | likes 20 | owner { 21 | id 22 | displayname 23 | email 24 | pictureurl 25 | } 26 | } 27 | } 28 | 29 | subscription memeAdded { 30 | memeAdded { 31 | id 32 | photourl 33 | likes 34 | owner { 35 | id 36 | displayname 37 | email 38 | pictureurl 39 | } 40 | } 41 | } 42 | 43 | query comments($memeid: ID!) { 44 | comments(memeid: $memeid) { 45 | id 46 | comment 47 | owner { 48 | id 49 | displayname 50 | email 51 | pictureurl 52 | } 53 | } 54 | } 55 | 56 | mutation postComment($memeid: ID!, $comment: String!, $owner: ID!){ 57 | postComment(memeid: $memeid, comment: $comment, owner: $owner){ 58 | id 59 | comment 60 | owner { 61 | id 62 | displayname 63 | email 64 | pictureurl 65 | } 66 | } 67 | } 68 | 69 | mutation likeMeme($memeid: ID!){ 70 | likeMeme(id: $memeid) 71 | } 72 | 73 | ## Profile section 74 | 75 | mutation createProfile($email: String!, $displayname: String!, $pictureurl: String!) { 76 | createProfile(email: $email, displayname: $displayname, pictureurl: $pictureurl) { 77 | id 78 | email 79 | displayname 80 | pictureurl 81 | } 82 | } 83 | 84 | query profile($email: String!) { 85 | profile(email:$email){ 86 | id 87 | email 88 | displayname 89 | pictureurl 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /examples/memeolist.tables.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS comment; 2 | DROP TABLE IF EXISTS meme; 3 | DROP TABLE IF EXISTS profile; 4 | 5 | CREATE TABLE profile ( 6 | id SERIAL NOT NULL PRIMARY KEY, 7 | email CHARACTER VARYING(100) NOT NULL, 8 | displayname CHARACTER VARYING(100) NOT NULL, 9 | pictureurl CHARACTER VARYING(500) NOT NULL 10 | ); 11 | 12 | CREATE TABLE meme ( 13 | id SERIAL NOT NULL PRIMARY KEY, 14 | photourl CHARACTER VARYING(500) NOT NULL, 15 | likes NUMERIC NOT NULL, 16 | owner SERIAL NOT NULL references profile(id) 17 | ); 18 | 19 | CREATE TABLE comment ( 20 | id SERIAL NOT NULL PRIMARY KEY, 21 | comment CHARACTER VARYING(500) NOT NULL, 22 | owner SERIAL NOT NULL references profile(id), 23 | memeid SERIAL NOT NULL references meme(id) 24 | ); 25 | -------------------------------------------------------------------------------- /examples/memeolist.variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "photourl": "https://i.imgur.com/W4dhL0b.jpg", 3 | "owner": "1", 4 | "email":"mmartin@rodhut.com", 5 | "displayname": "Martin Martinski", 6 | "pictureurl": "https://tinyurl.com/avataraerogear", 7 | "comment": "Like it!" 8 | } 9 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const config = require('./server/config') 2 | const DataSyncService = require('./DataSyncService') 3 | const { log } = require('./server/lib/util/logger') 4 | 5 | process.on('uncaughtException', log.error.bind(log)) 6 | process.on('unhandledRejection', log.error.bind(log)) 7 | 8 | async function start () { 9 | const syncService = new DataSyncService(config) 10 | const stopSignals = ['SIGTERM', 'SIGABRT', 'SIGQUIT', 'SIGINT'] 11 | 12 | stopSignals.forEach(signal => { 13 | process.on(signal, syncService.gracefulShutdown.bind(syncService, signal)) 14 | }) 15 | 16 | await syncService.initialize() 17 | await syncService.start() 18 | } 19 | 20 | start() 21 | -------------------------------------------------------------------------------- /initial_architecture_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aerogear-attic/data-sync-server/d7f2223dbb19df3b3d8d7083ab4523d46e53e5b2/initial_architecture_flow.png -------------------------------------------------------------------------------- /integration_test/auth.integration.test.gql.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const gql = require('graphql-tag') 4 | const crypto = require('crypto') 5 | 6 | module.exports.profileMutation = mutationName => { 7 | return { 8 | // language=GraphQL 9 | mutation: gql` 10 | mutation { 11 | ${mutationName} ( 12 | email: "jordan@example.com", 13 | displayname: "Michael Jordan", 14 | pictureurl:"http://example.com/mj.jpg" 15 | ) { 16 | id, 17 | email, 18 | displayname, 19 | pictureurl 20 | } 21 | } 22 | ` 23 | } 24 | } 25 | 26 | module.exports.allProfiles = { 27 | // language=GraphQL 28 | query: gql`{ 29 | allProfiles { 30 | id 31 | } 32 | }` 33 | } 34 | 35 | module.exports.createMeme = (owner) => { 36 | const randomUrl = `http://example.com/meme.jpg?${crypto.randomBytes(16).toString('hex')}` 37 | return { 38 | // language=GraphQL 39 | mutation: gql` 40 | mutation { 41 | createMeme ( 42 | owner: ${owner}, 43 | photourl: "${randomUrl}" 44 | ) { 45 | id, 46 | photourl, 47 | likes, 48 | owner { 49 | id 50 | } 51 | } 52 | } 53 | ` 54 | } 55 | } 56 | 57 | module.exports.allMemes = (withComments) => { 58 | const comments = withComments ? ',comments { id, comment }' : '' 59 | return { 60 | query: gql` 61 | query { 62 | allMemes { 63 | id, 64 | photourl, 65 | likes, 66 | owner { 67 | id 68 | } 69 | ${comments} 70 | } 71 | } 72 | ` 73 | } 74 | } 75 | 76 | module.exports.likeMeme = (id) => { 77 | return { 78 | mutation: gql` 79 | mutation { 80 | likeMeme (id: "${id}") 81 | } 82 | ` 83 | } 84 | } 85 | 86 | module.exports.postComment = (memeid, comment, owner) => { 87 | return { 88 | mutation: gql` 89 | mutation { 90 | postComment (memeid:"${memeid}",comment:"${comment}",owner:"${owner}") 91 | { 92 | id 93 | comment 94 | } 95 | } 96 | ` 97 | } 98 | } 99 | 100 | module.exports.allComments = { 101 | // language=GraphQL 102 | query: gql` 103 | query { 104 | allComments { 105 | id 106 | comment 107 | } 108 | } 109 | `} 110 | -------------------------------------------------------------------------------- /integration_test/auth.integration.test.js: -------------------------------------------------------------------------------- 1 | process.env.KEYCLOAK_CONFIG_FILE = require('path').resolve('./integration_test/config/keycloak.json') 2 | const { test } = require('ava') 3 | const auth = require('./util/auth') 4 | const axios = require('axios') 5 | const gqls = require('./auth.integration.test.gql') 6 | const localKeycloak = require('./auth.integration.test.keycloak') 7 | 8 | const context = { 9 | helper: undefined, 10 | testNote: 'auth, inmem', 11 | testPassword: 'admin', 12 | keycloakConfig: require(process.env.KEYCLOAK_CONFIG_FILE) 13 | } 14 | 15 | function modifyKeycloakServerUrl (url) { 16 | const fs = require('fs') 17 | 18 | context.keycloakConfig['auth-server-url'] = url 19 | fs.writeFileSync(process.env.KEYCLOAK_CONFIG_FILE, JSON.stringify(context.keycloakConfig)) 20 | } 21 | 22 | async function authenticate (test, username, password) { 23 | test.log(`Authenticating as ${username}`) 24 | const authHeaders = await auth.authenticateKeycloak(context.keycloakConfig, username, password) 25 | context.helper.resetApolloClient(authHeaders) 26 | test.log(`Authenticated as ${username}`) 27 | } 28 | 29 | test.before(async t => { 30 | // Used in Circle CI 31 | if (process.env.KEYCLOAK_HOST && process.env.KEYCLOAK_PORT) { 32 | modifyKeycloakServerUrl(`http://${process.env.KEYCLOAK_HOST}:${process.env.KEYCLOAK_PORT}/auth`) 33 | } 34 | await localKeycloak.prepareKeycloak(context.keycloakConfig['auth-server-url']) 35 | const Helper = require('./helper') 36 | const helper = new Helper() 37 | await helper.initialize() 38 | // delete the all the config 1-time before starting the tests 39 | await helper.deleteConfig() 40 | await helper.feedConfig('auth.complete.inmem.valid.memeo') 41 | await helper.triggerReload() 42 | 43 | context.helper = helper 44 | }) 45 | 46 | test.after.always(async t => { 47 | await localKeycloak.resetKeycloakConfiguration() 48 | }) 49 | 50 | test.beforeEach(async (t) => { 51 | context.helper.resetApolloClient() 52 | t.log('Cleared authentication headers') 53 | }) 54 | 55 | test.serial(`not authenticated query should fail (${context.testNote}`, async t => { 56 | let { port } = context.helper.syncService.app.server.address() 57 | try { 58 | await axios({method: 'post', 59 | url: `http://localhost:${port}/graphql`, 60 | data: { query: '{ allProfiles { id } }' }, 61 | maxRedirects: 0}) 62 | // shall not pass here, should be redirected to Keycloak, throws exception 63 | t.fail('unauthenticated request passed') 64 | } catch (e) { 65 | t.deepEqual(e.response.status, 302, 'Improper HTTP redirection to Keycloak') 66 | t.regex(e.response.headers.location, new RegExp(`^${context.keycloakConfig['auth-server-url']}.*`), 'Keycloak url not matching for the redirect') 67 | } 68 | }) 69 | 70 | test.serial(`should return empty list when no Profiles created yet (${context.testNote})`, async t => { 71 | await authenticate(t, 'test-admin', context.testPassword) 72 | const res = await context.helper.apolloClient.client.query(gqls.allProfiles) 73 | t.falsy(res.errors) 74 | t.deepEqual(res.data, { allProfiles: [] }) 75 | }) 76 | 77 | const checkProfile = (t, res, mutationName) => { 78 | t.falsy(res.errors) 79 | t.truthy(res.data[mutationName]) 80 | t.truthy(res.data[mutationName].id) 81 | t.deepEqual(res.data[mutationName].email, 'jordan@example.com') 82 | t.deepEqual(res.data[mutationName].displayname, 'Michael Jordan') 83 | t.deepEqual(res.data[mutationName].pictureurl, 'http://example.com/mj.jpg') 84 | } 85 | 86 | test.serial(`should create a Profile with proper client role (mutation client hasRole check) (${context.testNote})`, async t => { 87 | await authenticate(t, 'test-admin', context.testPassword) 88 | 89 | let res = await context.helper.apolloClient.client.mutate(gqls.profileMutation('createProfile')) 90 | 91 | checkProfile(t, res, 'createProfile') 92 | 93 | t.log(res) 94 | 95 | const createdId = res.data.createProfile.id 96 | 97 | res = await context.helper.apolloClient.client.query(gqls.allProfiles) 98 | t.falsy(res.errors) 99 | t.truthy(res.data.allProfiles) 100 | t.is(res.data.allProfiles.length, 1) 101 | t.is(res.data.allProfiles[0].id, createdId) 102 | }) 103 | 104 | const checkForbidden = (t, exception) => { 105 | t.truthy(exception.graphQLErrors) 106 | t.is(exception.graphQLErrors[0].extensions.code, 'FORBIDDEN') 107 | } 108 | 109 | const checkProfileCount = async (t, count) => { 110 | let res = await context.helper.apolloClient.client.query(gqls.allProfiles) 111 | t.falsy(res.errors) 112 | t.truthy(res.data.allProfiles) 113 | t.is(res.data.allProfiles.length, count) 114 | } 115 | 116 | test.serial(`shouldn't create a Profile without proper client role (mutation client hasRole check) (${context.testNote})`, async t => { 117 | await authenticate(t, 'test-voter', context.testPassword) 118 | try { 119 | await context.helper.apolloClient.client.mutate(gqls.profileMutation('createProfile')) 120 | t.fail('Profile was created without proper role') 121 | } catch (e) { 122 | checkForbidden(t, e) 123 | } 124 | await checkProfileCount(t, 1) 125 | }) 126 | 127 | test.serial(`shouldn't create a Profile without proper realm role (mutation realm hasRole check) (${context.testNote})`, async t => { 128 | await authenticate(t, 'test-voter', context.testPassword) 129 | 130 | try { 131 | await context.helper.apolloClient.client.mutate(gqls.profileMutation('createProfileRealm')) 132 | t.fail('Profile was created without proper role') 133 | } catch (e) { 134 | checkForbidden(t, e) 135 | } 136 | await checkProfileCount(t, 1) 137 | }) 138 | 139 | test.serial(`should create a Profile with proper realm role (mutation realm hasRole check) (${context.testNote})`, async t => { 140 | await authenticate(t, 'test-realm-role', context.testPassword) 141 | 142 | let res = await context.helper.apolloClient.client.mutate(gqls.profileMutation('createProfileRealm')) 143 | checkProfile(t, res, 'createProfileRealm') 144 | 145 | await checkProfileCount(t, 2) 146 | }) 147 | 148 | const createMeme = async t => { 149 | const meme = await context.helper.apolloClient.client.mutate(gqls.createMeme(1)) 150 | t.truthy(meme.data.createMeme.id) 151 | return meme 152 | } 153 | 154 | test.serial(`should be able to like a meme with proper role (hasRole array-check) (${context.testNote})`, async t => { 155 | const likeAndCheckCount = async () => { 156 | const meme = await createMeme(t) 157 | 158 | let likeCount = meme.data.createMeme.likes 159 | await context.helper.apolloClient.client.mutate(gqls.likeMeme(meme.data.createMeme.id)) 160 | 161 | let memes = await context.helper.apolloClient.client.query(gqls.allMemes(false)) 162 | const newMeme = memes.data.allMemes.filter(m => m.id === meme.data.createMeme.id)[0] 163 | t.truthy(newMeme) 164 | t.is(newMeme.likes, likeCount + 1) 165 | } 166 | 167 | await authenticate(t, 'test-voter', context.testPassword) 168 | await likeAndCheckCount() 169 | 170 | await authenticate(t, 'test-voter2', context.testPassword) 171 | await likeAndCheckCount() 172 | }) 173 | 174 | test.serial(`shouldn't be able to like a meme without proper role (hasRole array-check) (${context.testNote})`, async t => { 175 | await authenticate(t, 'test-norole', context.testPassword) 176 | const meme = await createMeme(t) 177 | 178 | let likeCount = meme.data.createMeme.likes 179 | try { 180 | await context.helper.apolloClient.client.mutate(gqls.likeMeme(meme.data.createMeme.id)) 181 | t.fail('Meme was created without proper role') 182 | } catch (e) { 183 | checkForbidden(t, e) 184 | let memes = await context.helper.apolloClient.client.query(gqls.allMemes(false)) 185 | const newMeme = memes.data.allMemes.filter(m => m.id === meme.data.createMeme.id)[0] 186 | t.truthy(newMeme) 187 | t.is(newMeme.likes, likeCount) 188 | } 189 | }) 190 | 191 | test.serial(`querying all comments with proper role (query hasRole check) (${context.testNote})`, async t => { 192 | await authenticate(t, 'test-admin', context.testPassword) 193 | const meme = await createMeme(t) 194 | 195 | let text = 'Lorem ipsum' 196 | const comment1 = await context.helper.apolloClient.client.mutate(gqls.postComment(meme.data.createMeme.id, text, 1)) 197 | t.truthy(comment1.data) 198 | t.falsy(comment1.errors) 199 | t.is(comment1.data.postComment.comment, text) 200 | 201 | text = 'Lorem ipsum2' 202 | const comment2 = await context.helper.apolloClient.client.mutate(gqls.postComment(meme.data.createMeme.id, text, 1)) 203 | t.truthy(comment2.data) 204 | t.falsy(comment2.errors) 205 | t.is(comment2.data.postComment.comment, text) 206 | const res = await context.helper.apolloClient.client.query(gqls.allComments) 207 | 208 | t.falsy(res.errors) 209 | t.truthy(res.data.allComments) 210 | 211 | const filtered = res.data.allComments.filter(c => (c.id === comment1.data.postComment.id || c.id === comment2.data.postComment.id)) 212 | t.is(filtered.length, 2) 213 | }) 214 | 215 | test.serial(`querying all comments without proper role (query hasRole check) (${context.testNote})`, async t => { 216 | await authenticate(t, 'test-norole', context.testPassword) 217 | 218 | try { 219 | await context.helper.apolloClient.client.query(gqls.allComments) 220 | t.fail('allComments shouldn\'t be able to query with this role') 221 | } catch (e) { 222 | checkForbidden(t, e) 223 | } 224 | }) 225 | 226 | test.serial(`query allMemes without field protected by hasRole (${context.testNote})`, async t => { 227 | await authenticate(t, 'test-norole', context.testPassword) 228 | 229 | const res = await context.helper.apolloClient.client.query(gqls.allMemes(false)) 230 | t.truthy(res.data.allMemes) 231 | if (res.data.allMemes.length === 0) { 232 | t.fail('allMemes field is empty, there should be some meme created') 233 | } 234 | }) 235 | 236 | test.serial(`query allMemes with field protected by hasRole and invalid role (${context.testNote})`, async t => { 237 | await authenticate(t, 'test-norole', context.testPassword) 238 | try { 239 | await context.helper.apolloClient.client.query(gqls.allMemes(true)) 240 | t.fail('query should be denied for this role') 241 | } catch (e) { 242 | checkForbidden(t, e) 243 | } 244 | }) 245 | 246 | test.serial(`query allMemes with field protected by hasRole and valid role (${context.testNote})`, async t => { 247 | await authenticate(t, 'test-admin', context.testPassword) 248 | const res = await context.helper.apolloClient.client.query(gqls.allMemes(true)) 249 | t.truthy(res.data.allMemes) 250 | if (res.data.allMemes.length === 0) { 251 | t.fail('allMemes field is empty, there should be some meme created') 252 | } 253 | t.truthy(res.data.allMemes[0].comments, 'Field doesn\'t contain comments') 254 | }) 255 | -------------------------------------------------------------------------------- /integration_test/auth.integration.test.keycloak.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const realmToImport = require('./config/realm-export.json') 3 | const { log } = require('../server/lib/util/logger') 4 | 5 | const config = { 6 | appRealmName: 'Memeolist', 7 | adminRealmName: 'master', 8 | resource: 'admin-cli', 9 | username: 'admin', 10 | password: 'admin', 11 | token: null, 12 | authServerUrl: null 13 | } 14 | 15 | const usersConfiguration = [ 16 | { name: 'test-admin', realmRole: 'admin', clientId: 'sync-server', clientRoleName: 'admin' }, 17 | { name: 'test-voter', realmRole: 'voter', clientId: 'sync-server', clientRoleName: 'voter' }, 18 | { name: 'test-voter2', realmRole: 'voter', clientId: 'sync-server', clientRoleName: 'voter' }, 19 | { name: 'test-realm-role', realmRole: 'admin' }, 20 | { name: 'test-norole' } 21 | ] 22 | 23 | async function authenticateKeycloak () { 24 | const res = await axios({ 25 | method: 'POST', 26 | url: `${config.authServerUrl}/realms/${config.adminRealmName}/protocol/openid-connect/token`, 27 | data: `client_id=${config.resource}&username=${config.username}&password=${config.password}&grant_type=password` 28 | }).catch((err) => { return log.error(err) }) 29 | return `Bearer ${res.data['access_token']}` 30 | } 31 | 32 | async function importRealm () { 33 | await axios({ 34 | method: 'POST', 35 | url: `${config.authServerUrl}/admin/realms`, 36 | data: realmToImport, 37 | headers: {'Authorization': config.token, 'Content-Type': 'application/json'} 38 | }).catch((err) => { return log.error(err) }) 39 | } 40 | 41 | async function getRealmRoles () { 42 | const res = await axios({ 43 | method: 'GET', 44 | url: `${config.authServerUrl}/admin/realms/${config.appRealmName}/roles`, 45 | headers: {'Authorization': config.token} 46 | }).catch((err) => { return log.error(err) }) 47 | 48 | return res.data 49 | } 50 | 51 | async function getClients () { 52 | const res = await axios({ 53 | method: 'GET', 54 | url: `${config.authServerUrl}/admin/realms/${config.appRealmName}/clients`, 55 | headers: {'Authorization': config.token} 56 | }).catch((err) => { return log.error(err) }) 57 | 58 | return res.data 59 | } 60 | 61 | async function getClientRoles (client) { 62 | const res = await axios({ 63 | method: 'GET', 64 | url: `${config.authServerUrl}/admin/realms/${config.appRealmName}/clients/${client.id}/roles`, 65 | headers: {'Authorization': config.token} 66 | }).catch((err) => { return log.error(err) }) 67 | return res.data 68 | } 69 | 70 | async function createUser (name) { 71 | const res = await axios({ 72 | method: 'post', 73 | url: `${config.authServerUrl}/admin/realms/${config.appRealmName}/users`, 74 | data: { 75 | 'username': name, 76 | 'credentials': [{'type': 'password', 'value': config.password, 'temporary': false}], 77 | 'enabled': true 78 | }, 79 | headers: {'Authorization': config.token, 'Content-Type': 'application/json'} 80 | }) 81 | if (res) { 82 | return res.headers.location 83 | } 84 | } 85 | 86 | async function assignRealmRoleToUser (userIdUrl, role) { 87 | const res = await axios({ 88 | method: 'POST', 89 | url: `${userIdUrl}/role-mappings/realm`, 90 | data: [role], 91 | headers: {'Authorization': config.token, 'Content-Type': 'application/json'} 92 | }).catch((err) => { return log.error(err) }) 93 | 94 | return res.data 95 | } 96 | 97 | async function assignClientRoleToUser (userIdUrl, client, role) { 98 | const res = await axios({ 99 | method: 'POST', 100 | url: `${userIdUrl}/role-mappings/clients/${client.id}`, 101 | data: [role], 102 | headers: {'Authorization': config.token, 'Content-Type': 'application/json'} 103 | }).catch((err) => { return log.error(err) }) 104 | return res.data 105 | } 106 | 107 | async function prepareKeycloak (authServerUrl) { 108 | config.authServerUrl = authServerUrl 109 | config.token = await authenticateKeycloak() 110 | await importRealm() 111 | const realmRoles = await getRealmRoles() 112 | const clients = await getClients() 113 | 114 | usersConfiguration.forEach(async user => { 115 | // Create a new user 116 | const userIdUrl = await createUser(user.name) 117 | // Assign realm role to user 118 | if (user.realmRole) { 119 | const selectedRealmRole = realmRoles.find(role => role.name === user.realmRole) 120 | await assignRealmRoleToUser(userIdUrl, selectedRealmRole) 121 | } 122 | // Assign client role to user 123 | if (user.clientId && user.clientRoleName) { 124 | const selectedClient = clients.find(client => client.clientId === user.clientId) 125 | const clientRoles = await getClientRoles(selectedClient) 126 | const selectedClientRole = clientRoles.find(clientRole => clientRole.name === user.clientRoleName) 127 | await assignClientRoleToUser(userIdUrl, selectedClient, selectedClientRole) 128 | } 129 | }) 130 | } 131 | 132 | async function resetKeycloakConfiguration () { 133 | await axios({ 134 | method: 'DELETE', 135 | url: `${config.authServerUrl}/admin/realms/${config.appRealmName}`, 136 | headers: {'Authorization': config.token} 137 | }).catch((err) => { return log.error(err) }) 138 | } 139 | 140 | module.exports = { 141 | prepareKeycloak, 142 | resetKeycloakConfiguration 143 | } 144 | -------------------------------------------------------------------------------- /integration_test/config.integration.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const gql = require('graphql-tag') 3 | 4 | let helper 5 | 6 | // also trigger a hot reload with that 7 | test.before(async t => { 8 | const Helper = require('./helper') 9 | helper = new Helper() 10 | 11 | await helper.initialize() 12 | 13 | // delete the all the config 1-time before starting the tests 14 | await helper.deleteConfig() 15 | await helper.triggerReload() 16 | }) 17 | 18 | test.serial('should run with default empty schema when no config provided', async t => { 19 | // default empty schema has a Query defined with name '_' 20 | const res = await helper.apolloClient.client.query({ 21 | query: gql`{ _ }` 22 | }) 23 | 24 | t.truthy(res) 25 | }) 26 | 27 | test.serial('should run with default empty schema when no provided config has empty schema', async t => { 28 | await helper.deleteConfig() 29 | await helper.feedConfig('simple.inmem.valid.empty.schema') 30 | await helper.triggerReload() 31 | 32 | // default empty schema has a Query defined with name '_' 33 | const res = await helper.apolloClient.client.query({ 34 | query: gql`{ _ }` 35 | }) 36 | 37 | t.truthy(res) 38 | }) 39 | 40 | test.serial('should pick up config changes', async t => { 41 | const query = helper.apolloClient.client.query({ 42 | query: gql`{ listNotes {id} }` 43 | }) 44 | 45 | t.throws(query) 46 | 47 | await helper.deleteConfig() 48 | await helper.feedConfig('simple.inmem.valid.notes') 49 | await helper.triggerReload() 50 | 51 | let res = await helper.apolloClient.client.query({ 52 | query: gql`{ listNotes {id} }` 53 | }) 54 | 55 | t.deepEqual(res.data, { listNotes: [] }) // no data since no mutation is executed 56 | }) 57 | 58 | test.serial('should use prev config when there is a schema syntax problem with the new config', async t => { 59 | // delete everything and feed a valid config 60 | await helper.deleteConfig() 61 | await helper.feedConfig('simple.inmem.valid.notes') 62 | await helper.triggerReload() // make server pick it up 63 | 64 | // delete everything and feed an invalid config 65 | await helper.deleteConfig() 66 | await helper.feedConfig('simple.inmem.invalid.bad.schema.syntax') 67 | await helper.triggerReload() // make server pick it up. it should still use the old valid config 68 | 69 | const res = await helper.apolloClient.client.query({ 70 | query: gql`{ listNotes {id} }` 71 | }) 72 | 73 | t.deepEqual(res.data, { listNotes: [] }) // no data since no mutation is executed 74 | }) 75 | 76 | test.serial('should not complain when there is a resolver not in the new schema', async t => { 77 | // delete everything and feed a valid config 78 | await helper.deleteConfig() 79 | await helper.triggerReload() // make server pick it up: it will use the default empty schema 80 | 81 | // delete everything and feed an invalid config 82 | await helper.deleteConfig() 83 | await helper.feedConfig('simple.inmem.valid.resolver.not.in.schema') 84 | // make server try to pick it up. 85 | // it should use the the new schema even though there is a resolver not in the schema 86 | await helper.triggerReload() 87 | 88 | const query = helper.apolloClient.client.query({ 89 | query: gql`{ listNotes {id} }` 90 | }) 91 | 92 | t.throws(query) 93 | 94 | // default empty schema has a Query defined with name '_' 95 | const res = await helper.apolloClient.client.query({ 96 | query: gql`{ someQuery }` 97 | }) 98 | 99 | t.truthy(res) 100 | }) 101 | 102 | // Apollo doesn't complain about this case in advance! 103 | test.serial('should return null when executing a query with missing resolver', async t => { 104 | // delete everything and feed the config 105 | await helper.deleteConfig() 106 | await helper.feedConfig('simple.inmem.invalid.notes.no.resolver.for.query') 107 | await helper.triggerReload() // make server try to pick it up. it should be able to use the new schema. 108 | 109 | let res = await helper.apolloClient.client.query({ 110 | query: gql`{ listNotes {id} }` 111 | }) 112 | 113 | t.deepEqual(res.data, { listNotes: [] }) // no data since no mutation is executed 114 | 115 | res = await helper.apolloClient.client.query({ 116 | query: gql`{ foo }` 117 | }) 118 | 119 | t.deepEqual(res.data, { foo: null }) 120 | }) 121 | 122 | test.serial('should return error when calling a query that does not exist', async t => { 123 | // delete everything and feed the config 124 | await helper.deleteConfig() 125 | await helper.feedConfig('simple.inmem.valid.notes') 126 | await helper.triggerReload() 127 | 128 | const res = await helper.apolloClient.client.query({ 129 | query: gql`{ listNotes {id} }` 130 | }) 131 | 132 | t.deepEqual(res.data, { listNotes: [] }) // no data since no mutation is executed 133 | 134 | const query = helper.apolloClient.client.query({ 135 | query: gql`{ FOO }` 136 | }) 137 | 138 | return t.throws(query) 139 | }) 140 | -------------------------------------------------------------------------------- /integration_test/config/auth.complete.inmem.valid.memeo.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const time = new Date() 4 | 5 | const datasources = [ 6 | { 7 | id: 1, 8 | name: 'nedb_memeolist', 9 | type: 'InMemory', 10 | config: '{"options":{"timestampData":true}}', 11 | createdAt: time, 12 | updatedAt: time 13 | } 14 | ] 15 | 16 | const schema = require('./auth.complete.memeo.schema.only') 17 | 18 | const subscriptions = [ 19 | { 20 | type: 'Subscription', 21 | field: 'memeAdded', 22 | GraphQLSchemaId: 1, 23 | topic: 'memeCreated', 24 | filter: JSON.stringify({ 25 | match: ['$payload.memeAdded.photourl', 'https://.*'] 26 | }), 27 | createdAt: time, 28 | updatedAt: time 29 | } 30 | ] 31 | 32 | const resolvers = [ 33 | { 34 | type: 'Meme', 35 | field: 'owner', 36 | DataSourceId: 1, 37 | GraphQLSchemaId: 1, 38 | requestMapping: `{"operation": "find", "query": 39 | {"_type":"profile", "id": "{{context.parent.owner}}"}}`, 40 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 41 | createdAt: time, 42 | updatedAt: time 43 | }, 44 | { 45 | type: 'Meme', 46 | field: 'comments', 47 | DataSourceId: 1, 48 | GraphQLSchemaId: 1, 49 | requestMapping: `{"operation": "find", "query": 50 | {"_type":"comment", "memeid": "{{context.parent.id}}"}}`, 51 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 52 | createdAt: time, 53 | updatedAt: time 54 | }, 55 | { 56 | type: 'Profile', 57 | field: 'memes', 58 | DataSourceId: 1, 59 | GraphQLSchemaId: 1, 60 | requestMapping: `{"operation": "find", "query": 61 | {"_type":"meme", "owner": "{{context.parent.id}}"}}`, 62 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 63 | createdAt: time, 64 | updatedAt: time 65 | }, 66 | { 67 | type: 'Query', 68 | field: 'allMemes', 69 | DataSourceId: 1, 70 | GraphQLSchemaId: 1, 71 | preHook: '', 72 | postHook: '', 73 | requestMapping: '{"operation": "find", "query": {"_type":"meme"}}', 74 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 75 | createdAt: time, 76 | updatedAt: time 77 | }, 78 | { 79 | type: 'Query', 80 | field: 'allProfiles', 81 | DataSourceId: 1, 82 | GraphQLSchemaId: 1, 83 | preHook: '', 84 | postHook: '', 85 | requestMapping: '{"operation": "find", "query": {"_type":"profile"}}', 86 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 87 | createdAt: time, 88 | updatedAt: time 89 | }, 90 | { 91 | type: 'Query', 92 | field: 'allComments', 93 | DataSourceId: 1, 94 | GraphQLSchemaId: 1, 95 | preHook: '', 96 | postHook: '', 97 | requestMapping: '{"operation": "find", "query": {"_type":"comment"}}', 98 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 99 | createdAt: time, 100 | updatedAt: time 101 | }, 102 | { 103 | type: 'Query', 104 | field: 'profile', 105 | DataSourceId: 1, 106 | GraphQLSchemaId: 1, 107 | requestMapping: '{"operation": "find", "query": {"_type":"profile", "email": "{{context.arguments.email}}" }}', 108 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 109 | createdAt: time, 110 | updatedAt: time 111 | }, 112 | { 113 | type: 'Mutation', 114 | field: 'createMeme', 115 | DataSourceId: 1, 116 | GraphQLSchemaId: 1, 117 | preHook: '', 118 | postHook: '', 119 | requestMapping: `{ 120 | "operation": "insert", 121 | "doc": { 122 | "_type":"meme", 123 | "photourl": "{{context.arguments.photourl}}", 124 | "owner": "{{context.arguments.owner}}", 125 | "likes": 0 126 | } 127 | }`, 128 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 129 | publish: JSON.stringify({ 130 | topic: 'memeCreated', 131 | payload: `{ 132 | "memeAdded": {{ toJSON context.result }} 133 | }` 134 | }), 135 | createdAt: time, 136 | updatedAt: time 137 | }, 138 | { 139 | type: 'Mutation', 140 | field: 'createProfile', 141 | DataSourceId: 1, 142 | GraphQLSchemaId: 1, 143 | requestMapping: `{ 144 | "operation": "insert", 145 | "doc": { 146 | "_type":"profile", 147 | "email": "{{context.arguments.email}}", 148 | "displayname": "{{context.arguments.displayname}}", 149 | "pictureurl": "{{context.arguments.pictureurl}}" 150 | } 151 | }`, 152 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 153 | createdAt: time, 154 | updatedAt: time 155 | }, 156 | { 157 | type: 'Mutation', 158 | field: 'createProfileRealm', 159 | DataSourceId: 1, 160 | GraphQLSchemaId: 1, 161 | requestMapping: `{ 162 | "operation": "insert", 163 | "doc": { 164 | "_type":"profile", 165 | "email": "{{context.arguments.email}}", 166 | "displayname": "{{context.arguments.displayname}}", 167 | "pictureurl": "{{context.arguments.pictureurl}}" 168 | } 169 | }`, 170 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 171 | createdAt: time, 172 | updatedAt: time 173 | }, 174 | { 175 | type: 'Mutation', 176 | field: 'likeMeme', 177 | DataSourceId: 1, 178 | GraphQLSchemaId: 1, 179 | requestMapping: `{ 180 | "operation": "update", 181 | "query": {"_id": "{{context.arguments.id}}", "_type":"meme"}, 182 | "update": { 183 | "$inc": { "likes" : 1 } 184 | } 185 | }`, 186 | responseMapping: 'true', 187 | createdAt: time, 188 | updatedAt: time 189 | }, { 190 | type: 'Mutation', 191 | field: 'postComment', 192 | DataSourceId: 1, 193 | GraphQLSchemaId: 1, 194 | requestMapping: `{ 195 | "operation": "insert", 196 | "doc": { 197 | "_type":"comment", 198 | "comment": "{{context.arguments.comment}}", 199 | "owner": "{{context.arguments.owner}}", 200 | "memeid": "{{context.arguments.memeid}}" 201 | } 202 | }`, 203 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 204 | createdAt: time, 205 | updatedAt: time 206 | } 207 | ] 208 | 209 | module.exports = { 210 | up: async (queryInterface, Sequelize) => { 211 | await queryInterface.bulkInsert('DataSources', datasources, {}) 212 | await queryInterface.bulkInsert('GraphQLSchemas', [schema], {}) 213 | await queryInterface.bulkInsert('Subscriptions', subscriptions, {}) 214 | return queryInterface.bulkInsert('Resolvers', resolvers, {}) 215 | } 216 | } 217 | 218 | // IMPORTANT: please describe the config here. things would be complicated for test maintainers otherwise 219 | module.exports.description = 'A complex valid config that uses a in-mem data source' 220 | -------------------------------------------------------------------------------- /integration_test/config/auth.complete.memeo.schema.only.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const time = new Date() 3 | const schema = { 4 | id: 1, 5 | name: 'default', 6 | schema: ` 7 | type Profile { 8 | id: ID! 9 | email: String! 10 | displayname: String 11 | pictureurl: String 12 | memes: [Meme!]! 13 | } 14 | 15 | type Meme { 16 | id: ID! 17 | photourl: String! 18 | likes: Int! 19 | owner: [Profile!]! 20 | comments: [Comment!]! @hasRole(role: ["admin","commentViewer" ]) 21 | } 22 | 23 | type Comment { 24 | id: ID! 25 | owner: String! 26 | comment: String! 27 | } 28 | 29 | type Query { 30 | allMemes:[Meme!]! 31 | profile(email: String!): [Profile]! 32 | allProfiles:[Profile!]! 33 | allComments: [Comment!]! @hasRole(role: "admin") 34 | } 35 | 36 | type Mutation { 37 | createProfile(email: String!, displayname: String!, pictureurl: String!):Profile! @hasRole(role: "admin") 38 | createProfileRealm(email: String!, displayname: String!, pictureurl: String!):Profile! @hasRole(role: "realm:admin") 39 | createMeme(owner: ID!, photourl: String!):Meme! 40 | likeMeme(id: ID!): Boolean @hasRole(role: ["voter","test"]) 41 | postComment(memeid: ID!, comment: String!, owner: String!): Comment! 42 | } 43 | 44 | type Subscription { 45 | memeAdded(photourl: String):Meme! @hasRole(role: "commentViewer") 46 | } 47 | `, 48 | createdAt: time, 49 | updatedAt: time 50 | } 51 | 52 | module.exports = schema 53 | -------------------------------------------------------------------------------- /integration_test/config/complete.inmem.valid.memeo.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const time = new Date() 4 | 5 | const datasources = [ 6 | { 7 | id: 2, 8 | name: 'nedb_memeolist', 9 | type: 'InMemory', 10 | config: '{"options":{"timestampData":true}}', 11 | createdAt: time, 12 | updatedAt: time 13 | } 14 | ] 15 | 16 | const schema = require('./complete.valid.memeo.schema.only') 17 | 18 | const subscriptions = [ 19 | { 20 | type: 'Subscription', 21 | field: 'memeAdded', 22 | GraphQLSchemaId: 2, 23 | createdAt: time, 24 | updatedAt: time 25 | } 26 | ] 27 | 28 | const resolvers = [ 29 | { 30 | type: 'Query', 31 | field: 'allMemes', 32 | GraphQLSchemaId: 2, 33 | DataSourceId: 2, 34 | requestMapping: '{"operation": "find", "query": {"_type":"meme"}}', 35 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 36 | createdAt: time, 37 | updatedAt: time 38 | }, 39 | { 40 | type: 'Query', 41 | field: 'profile', 42 | GraphQLSchemaId: 2, 43 | DataSourceId: 2, 44 | requestMapping: `{ 45 | "operation": "findOne", 46 | "query": {"_type":"profile", "email": "{{context.arguments.email}}" } 47 | }`, 48 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 49 | createdAt: time, 50 | updatedAt: time 51 | }, 52 | { 53 | type: 'Mutation', 54 | field: 'createMeme', 55 | GraphQLSchemaId: 2, 56 | DataSourceId: 2, 57 | requestMapping: `{ 58 | "operation": "insert", 59 | "doc": { 60 | "_type":"meme", 61 | "photoUrl": "{{context.arguments.photoUrl}}", 62 | "ownerId": "{{context.arguments.ownerId}}" 63 | } 64 | }`, 65 | publish: 'memeAdded', 66 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 67 | createdAt: time, 68 | updatedAt: time 69 | }, 70 | { 71 | type: 'Profile', 72 | field: 'memes', 73 | GraphQLSchemaId: 2, 74 | DataSourceId: 2, 75 | requestMapping: '{"operation": "find", "query": {"_type":"meme", "ownerId": "{{context.parent.id}}"}}', 76 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 77 | createdAt: time, 78 | updatedAt: time 79 | }, 80 | { 81 | type: 'Query', 82 | field: 'allProfiles', 83 | GraphQLSchemaId: 2, 84 | DataSourceId: 2, 85 | requestMapping: '{"operation": "find", "query": {"_type":"profile"}}', 86 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 87 | createdAt: time, 88 | updatedAt: time 89 | }, 90 | { 91 | type: 'Mutation', 92 | field: 'createProfile', 93 | GraphQLSchemaId: 2, 94 | DataSourceId: 2, 95 | requestMapping: `{ 96 | "operation": "insert", 97 | "doc": { 98 | "_type":"profile", 99 | "email": "{{context.arguments.email}}", 100 | "displayName": "{{context.arguments.displayName}}", 101 | "biography": "{{context.arguments.biography}}", 102 | "avatarUrl": "{{context.arguments.avatarUrl}}", 103 | "memes": [] 104 | } 105 | }`, 106 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 107 | createdAt: time, 108 | updatedAt: time 109 | }, 110 | { 111 | type: 'Mutation', 112 | field: 'updateProfile', 113 | GraphQLSchemaId: 2, 114 | DataSourceId: 2, 115 | requestMapping: `{ 116 | "operation": "update", 117 | "query": {"_type":"profile", "_id": "{{context.arguments.id}}" }, 118 | "update": { 119 | "$set": { 120 | "email": "{{context.arguments.email}}", 121 | "displayName": "{{context.arguments.displayName}}", 122 | "biography": "{{context.arguments.biography}}", 123 | "avatarUrl": "{{context.arguments.avatarUrl}}" 124 | } 125 | }, 126 | "options": { 127 | "returnUpdatedDocs": true 128 | } 129 | }`, 130 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 131 | createdAt: time, 132 | updatedAt: time 133 | }, 134 | { 135 | type: 'Mutation', 136 | field: 'deleteProfile', 137 | GraphQLSchemaId: 2, 138 | DataSourceId: 2, 139 | requestMapping: `{ 140 | "operation": "remove", 141 | "query": {"_type":"profile", "_id": "{{context.arguments.id}}" } 142 | }`, 143 | responseMapping: '{{toBoolean context.result}}', 144 | createdAt: time, 145 | updatedAt: time 146 | } 147 | ] 148 | 149 | module.exports = { 150 | up: async (queryInterface, Sequelize) => { 151 | await queryInterface.bulkInsert('DataSources', datasources, {}) 152 | await queryInterface.bulkInsert('GraphQLSchemas', [schema], {}) 153 | await queryInterface.bulkInsert('Subscriptions', subscriptions, {}) 154 | return queryInterface.bulkInsert('Resolvers', resolvers, {}) 155 | } 156 | } 157 | 158 | // IMPORTANT: please describe the config here. things would be complicated for test maintainers otherwise 159 | module.exports.description = 'A complex valid config that uses a in-mem data source' 160 | -------------------------------------------------------------------------------- /integration_test/config/complete.postgres.valid.memeo.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const time = new Date() 4 | 5 | const memeoListDbHost = process.env.MEMEOLIST_DB_HOST || '127.0.0.1' 6 | const memeoListDbPort = process.env.MEMEOLIST_DB_PORT || '15432' 7 | 8 | const datasources = [ 9 | { 10 | id: 2, 11 | name: 'nedb_postgres', 12 | type: 'Postgres', 13 | config: `{"options":{ 14 | "user": "postgresql", 15 | "password": "postgres", 16 | "database": "memeolist_db", 17 | "host": "${memeoListDbHost}", 18 | "port": "${memeoListDbPort}", 19 | "dialect": "postgres" 20 | }}`, 21 | createdAt: time, 22 | updatedAt: time 23 | } 24 | ] 25 | 26 | const schema = require('./complete.valid.memeo.schema.only') 27 | 28 | const subscriptions = [ 29 | { 30 | type: 'Subscription', 31 | field: 'memeAdded', 32 | GraphQLSchemaId: 2, 33 | createdAt: time, 34 | updatedAt: time 35 | } 36 | ] 37 | 38 | const resolvers = [ 39 | { 40 | type: 'Query', 41 | field: 'allMemes', 42 | GraphQLSchemaId: 2, 43 | DataSourceId: 2, 44 | requestMapping: 'SELECT "id", "photoUrl", "ownerId" FROM "Meme"', 45 | responseMapping: '{{ toJSON context.result }}', 46 | createdAt: time, 47 | updatedAt: time 48 | }, 49 | { 50 | type: 'Query', 51 | field: 'profile', 52 | GraphQLSchemaId: 2, 53 | DataSourceId: 2, 54 | requestMapping: `SELECT "id", "email", "displayName", "biography", "avatarUrl" FROM "Profile" where "email"='{{context.arguments.email}}'`, 55 | responseMapping: '{{ toJSON context.result.[0] }}', 56 | createdAt: time, 57 | updatedAt: time 58 | }, 59 | { 60 | type: 'Mutation', 61 | field: 'createMeme', 62 | GraphQLSchemaId: 2, 63 | DataSourceId: 2, 64 | requestMapping: ` 65 | INSERT INTO "Meme" ("ownerId", "photoUrl") 66 | VALUES ('{{context.arguments.ownerId}}', '{{context.arguments.photoUrl}}') 67 | RETURNING *;`, 68 | responseMapping: '{{ toJSON context.result.[0] }}', 69 | publish: 'memeAdded', 70 | createdAt: time, 71 | updatedAt: time 72 | }, 73 | { 74 | type: 'Profile', 75 | field: 'memes', 76 | GraphQLSchemaId: 2, 77 | DataSourceId: 2, 78 | publish: 'memeAdded', 79 | requestMapping: `SELECT "id", "ownerId", "photoUrl" FROM "Meme" where "ownerId"={{context.parent.id}}`, 80 | responseMapping: '{{ toJSON context.result }}', 81 | createdAt: time, 82 | updatedAt: time 83 | }, 84 | { 85 | type: 'Query', 86 | field: 'allProfiles', 87 | GraphQLSchemaId: 2, 88 | DataSourceId: 2, 89 | requestMapping: 'SELECT "id", "email", "displayName", "biography", "avatarUrl" FROM "Profile"', 90 | responseMapping: '{{ toJSON context.result }}', 91 | createdAt: time, 92 | updatedAt: time 93 | }, 94 | { 95 | type: 'Mutation', 96 | field: 'createProfile', 97 | GraphQLSchemaId: 2, 98 | DataSourceId: 2, 99 | requestMapping: ` 100 | INSERT INTO "Profile" ("email", "displayName", "biography", "avatarUrl") 101 | VALUES ('{{context.arguments.email}}', '{{context.arguments.displayName}}','{{context.arguments.biography}}', '{{context.arguments.avatarUrl}}') 102 | RETURNING *;`, 103 | responseMapping: '{{ toJSON context.result.[0] }}', 104 | createdAt: time, 105 | updatedAt: time 106 | }, 107 | { 108 | type: 'Mutation', 109 | field: 'updateProfile', 110 | GraphQLSchemaId: 2, 111 | DataSourceId: 2, 112 | requestMapping: ` 113 | UPDATE "Profile" SET 114 | "email" = '{{ context.arguments.email }}', 115 | "displayName" = '{{ context.arguments.displayName }}', 116 | "biography" = '{{ context.arguments.biography }}', 117 | "avatarUrl" = '{{ context.arguments.avatarUrl }}' 118 | WHERE "id"='{{context.arguments.id}}' 119 | RETURNING *;`, 120 | responseMapping: '{{ toJSON context.result.[0] }}', 121 | createdAt: time, 122 | updatedAt: time 123 | }, 124 | { 125 | type: 'Mutation', 126 | field: 'deleteProfile', 127 | GraphQLSchemaId: 2, 128 | DataSourceId: 2, 129 | requestMapping: ` 130 | DELETE from "Profile" 131 | WHERE "id"='{{context.arguments.id}}' 132 | RETURNING *;`, 133 | responseMapping: '{{ toJSON context.result.[0] }}', 134 | // responseMapping: '{{toBoolean context.result}}', 135 | createdAt: time, 136 | updatedAt: time 137 | } 138 | ] 139 | 140 | module.exports = { 141 | up: async (queryInterface, Sequelize) => { 142 | await queryInterface.bulkInsert('DataSources', datasources, {}) 143 | await queryInterface.bulkInsert('GraphQLSchemas', [schema], {}) 144 | await queryInterface.bulkInsert('Subscriptions', subscriptions, {}) 145 | return queryInterface.bulkInsert('Resolvers', resolvers, {}) 146 | } 147 | } 148 | 149 | // IMPORTANT: please describe the config here. things would be complicated for test maintainers otherwise 150 | module.exports.description = 'A complex valid config that uses a Postgres data source' 151 | -------------------------------------------------------------------------------- /integration_test/config/complete.valid.memeo.schema.only.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const time = new Date() 4 | 5 | module.exports = { 6 | id: 2, 7 | name: 'default', 8 | // language=GraphQL 9 | schema: ` 10 | 11 | type Profile { 12 | id: ID! @isUnique 13 | email: String! @isUnique 14 | displayName: String! 15 | biography: String! 16 | avatarUrl: String! 17 | memes: [Meme]! 18 | } 19 | 20 | type Meme { 21 | id: ID! @isUnique 22 | photoUrl: String! 23 | ownerId: String! 24 | } 25 | 26 | type Query { 27 | allProfiles:[Profile!]! 28 | profile(email: String!):Profile 29 | allMemes:[Meme!]! 30 | } 31 | 32 | type Mutation { 33 | createProfile(email: String!, displayName: String!, biography: String!, avatarUrl: String!):Profile! 34 | updateProfile(id: ID!, email: String!, displayName: String!, biography: String!, avatarUrl: String!):Profile 35 | deleteProfile(id: ID!):Boolean! 36 | createMeme(ownerId: String!, photoUrl: String!):Meme! 37 | } 38 | 39 | type Subscription { 40 | memeAdded(photoUrl: String):Meme! 41 | } 42 | 43 | `, 44 | createdAt: time, 45 | updatedAt: time 46 | } 47 | -------------------------------------------------------------------------------- /integration_test/config/keycloak.json: -------------------------------------------------------------------------------- 1 | { 2 | "realm": "Memeolist", 3 | "auth-server-url": "http://localhost:8080/auth", 4 | "ssl-required": "external", 5 | "resource": "sync-server", 6 | "public-client": true 7 | } 8 | -------------------------------------------------------------------------------- /integration_test/config/simple.inmem.invalid.bad.schema.syntax.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const time = new Date() 4 | 5 | const datasources = [ 6 | { 7 | id: 1, 8 | name: 'nedb_notes', 9 | type: 'InMemory', 10 | config: '{"options":{"timestampData":true}}', 11 | createdAt: time, 12 | updatedAt: time 13 | } 14 | ] 15 | 16 | const notesSchema = { 17 | id: 1, 18 | name: 'default', 19 | // language=GraphQL 20 | schema: ` 21 | 22 | FOO 23 | 24 | `, 25 | createdAt: time, 26 | updatedAt: time 27 | } 28 | 29 | const resolvers = [ 30 | { 31 | type: 'Query', 32 | field: 'BAR', 33 | DataSourceId: 1, 34 | GraphQLSchemaId: 1, 35 | requestMapping: 'DOES NOT MATTER', 36 | responseMapping: 'DOES NOT MATTER', 37 | createdAt: time, 38 | updatedAt: time 39 | } 40 | ] 41 | 42 | module.exports = { 43 | up: async (queryInterface, Sequelize) => { 44 | await queryInterface.bulkInsert('DataSources', datasources, {}) 45 | await queryInterface.bulkInsert('GraphQLSchemas', [notesSchema], {}) 46 | return queryInterface.bulkInsert('Resolvers', resolvers, {}) 47 | } 48 | } 49 | 50 | // IMPORTANT: please describe the config here. things would be complicated for test maintainers otherwise 51 | module.exports.description = 'A simplified valid config that uses a in-mem data source with notes schema' 52 | -------------------------------------------------------------------------------- /integration_test/config/simple.inmem.invalid.notes.no.resolver.for.query.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const time = new Date() 4 | 5 | const datasources = [ 6 | { 7 | id: 1, 8 | name: 'nedb_notes', 9 | type: 'InMemory', 10 | config: '{"options":{"timestampData":true}}', 11 | createdAt: time, 12 | updatedAt: time 13 | } 14 | ] 15 | 16 | const notesSchema = { 17 | id: 1, 18 | name: 'default', 19 | // language=GraphQL 20 | schema: ` 21 | 22 | schema { 23 | query: Query 24 | } 25 | 26 | type Query { 27 | listNotes: [Note] 28 | foo: Boolean # no resolver for this 29 | } 30 | 31 | type Note { 32 | id: String 33 | title: String 34 | } 35 | 36 | `, 37 | createdAt: time, 38 | updatedAt: time 39 | } 40 | 41 | const resolvers = [ 42 | { 43 | type: 'Query', 44 | field: 'listNotes', 45 | DataSourceId: 1, 46 | GraphQLSchemaId: 1, 47 | requestMapping: '{"operation": "find","query": {}}', 48 | responseMapping: '{{toJSON context.result}}', 49 | createdAt: time, 50 | updatedAt: time 51 | } 52 | ] 53 | 54 | module.exports = { 55 | up: async (queryInterface, Sequelize) => { 56 | await queryInterface.bulkInsert('DataSources', datasources, {}) 57 | await queryInterface.bulkInsert('GraphQLSchemas', [notesSchema], {}) 58 | return queryInterface.bulkInsert('Resolvers', resolvers, {}) 59 | } 60 | } 61 | 62 | // IMPORTANT: please describe the config here. things would be complicated for test maintainers otherwise 63 | module.exports.description = 'A simplified invalid config that uses a in-mem data source with a query which does not have a resolver' 64 | -------------------------------------------------------------------------------- /integration_test/config/simple.inmem.valid.empty.schema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const time = new Date() 4 | 5 | const datasources = [ 6 | { 7 | id: 1, 8 | name: 'nedb_notes', 9 | type: 'InMemory', 10 | config: '{"options":{"timestampData":true}}', 11 | createdAt: time, 12 | updatedAt: time 13 | } 14 | ] 15 | 16 | const notesSchema = { 17 | id: 1, 18 | name: 'default', 19 | // language=GraphQL 20 | schema: '', 21 | createdAt: time, 22 | updatedAt: time 23 | } 24 | 25 | const resolvers = [ 26 | { 27 | type: 'Query', 28 | field: 'listNotes', 29 | DataSourceId: 1, 30 | GraphQLSchemaId: 1, 31 | requestMapping: 'DOES NOT MATTER', 32 | responseMapping: 'DOES NOT MATTER', 33 | createdAt: time, 34 | updatedAt: time 35 | } 36 | ] 37 | 38 | module.exports = { 39 | up: async (queryInterface, Sequelize) => { 40 | await queryInterface.bulkInsert('DataSources', datasources, {}) 41 | await queryInterface.bulkInsert('GraphQLSchemas', [notesSchema], {}) 42 | return queryInterface.bulkInsert('Resolvers', resolvers, {}) 43 | } 44 | } 45 | 46 | // IMPORTANT: please describe the config here. things would be complicated for test maintainers otherwise 47 | module.exports.description = 'A simplified valid config that uses a in-mem data source with empty schema' 48 | -------------------------------------------------------------------------------- /integration_test/config/simple.inmem.valid.notes.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const time = new Date() 4 | 5 | const datasources = [ 6 | { 7 | id: 1, 8 | name: 'nedb_notes', 9 | type: 'InMemory', 10 | config: '{"options":{"timestampData":true}}', 11 | createdAt: time, 12 | updatedAt: time 13 | } 14 | ] 15 | 16 | const notesSchema = { 17 | id: 1, 18 | name: 'default', 19 | // language=GraphQL 20 | schema: ` 21 | 22 | schema { 23 | query: Query 24 | } 25 | 26 | type Query { 27 | listNotes: [Note] 28 | } 29 | 30 | type Note { 31 | id: String 32 | title: String 33 | } 34 | 35 | `, 36 | createdAt: time, 37 | updatedAt: time 38 | } 39 | 40 | const resolvers = [ 41 | { 42 | type: 'Query', 43 | field: 'listNotes', 44 | DataSourceId: 1, 45 | GraphQLSchemaId: 1, 46 | requestMapping: '{"operation": "find","query": {}}', 47 | responseMapping: '{{toJSON context.result}}', 48 | createdAt: time, 49 | updatedAt: time 50 | } 51 | ] 52 | 53 | module.exports = { 54 | up: async (queryInterface, Sequelize) => { 55 | await queryInterface.bulkInsert('DataSources', datasources, {}) 56 | await queryInterface.bulkInsert('GraphQLSchemas', [notesSchema], {}) 57 | return queryInterface.bulkInsert('Resolvers', resolvers, {}) 58 | } 59 | } 60 | 61 | // IMPORTANT: please describe the config here. things would be complicated for test maintainers otherwise 62 | module.exports.description = 'A simplified valid config that uses a in-mem data source with notes schema' 63 | -------------------------------------------------------------------------------- /integration_test/config/simple.inmem.valid.resolver.not.in.schema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const time = new Date() 4 | 5 | const datasources = [ 6 | { 7 | id: 1, 8 | name: 'nedb_notes', 9 | type: 'InMemory', 10 | config: '{"options":{"timestampData":true}}', 11 | createdAt: time, 12 | updatedAt: time 13 | } 14 | ] 15 | 16 | const notesSchema = { 17 | id: 1, 18 | name: 'default', 19 | // language=GraphQL 20 | schema: ` 21 | 22 | schema { 23 | query: Query 24 | } 25 | 26 | type Query { 27 | someQuery: Boolean 28 | } 29 | 30 | `, 31 | createdAt: time, 32 | updatedAt: time 33 | } 34 | 35 | const resolvers = [ 36 | { 37 | type: 'Query', 38 | field: 'DOES_NOT_EXIST_IN_THE_SCHEMA', 39 | DataSourceId: 1, 40 | GraphQLSchemaId: 1, 41 | requestMapping: 'DOES NOT MATTER', 42 | responseMapping: 'DOES NOT MATTER', 43 | createdAt: time, 44 | updatedAt: time 45 | } 46 | ] 47 | 48 | module.exports = { 49 | up: async (queryInterface, Sequelize) => { 50 | await queryInterface.bulkInsert('DataSources', datasources, {}) 51 | await queryInterface.bulkInsert('GraphQLSchemas', [notesSchema], {}) 52 | return queryInterface.bulkInsert('Resolvers', resolvers, {}) 53 | } 54 | } 55 | 56 | // IMPORTANT: please describe the config here. things would be complicated for test maintainers otherwise 57 | module.exports.description = 'A simplified invalid config that has a resolver that does not have a query in the schema' 58 | -------------------------------------------------------------------------------- /integration_test/datasource.integration.test.base.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const gql = require('graphql-tag') 3 | 4 | /// //////////////// NOTE ///////////////////////////////////// 5 | /// /////////// order of tests are imporant!/////////////////// 6 | /// //////////////// NOTE ///////////////////////////////////// 7 | 8 | module.exports = function (context) { 9 | test.serial(`should return empty list when no Profiles created yet (${context.testNote})`, async t => { 10 | const res = await context.helper.apolloClient.client.query({ 11 | // language=GraphQL 12 | query: gql`{ 13 | allProfiles { 14 | id 15 | } 16 | }` 17 | }) 18 | 19 | t.falsy(res.errors) 20 | t.deepEqual(res.data, { allProfiles: [] }) 21 | }) 22 | 23 | test.serial(`should create a Profile (${context.testNote})`, async t => { 24 | let res = await context.helper.apolloClient.client.mutate({ 25 | // language=GraphQL 26 | mutation: gql` 27 | mutation { 28 | createProfile ( 29 | email: "jordan@example.com", 30 | displayName: "Michael Jordan", 31 | biography:"Nr #23!", 32 | avatarUrl:"http://example.com/mj.jpg" 33 | ) { 34 | id, 35 | email, 36 | displayName, 37 | biography, 38 | avatarUrl 39 | } 40 | } 41 | ` 42 | }) 43 | 44 | t.log(res) 45 | 46 | t.falsy(res.errors) 47 | t.truthy(res.data.createProfile) 48 | t.truthy(res.data.createProfile.id) 49 | t.deepEqual(res.data.createProfile.email, 'jordan@example.com') 50 | t.deepEqual(res.data.createProfile.displayName, 'Michael Jordan') 51 | t.deepEqual(res.data.createProfile.biography, 'Nr #23!') 52 | t.deepEqual(res.data.createProfile.avatarUrl, 'http://example.com/mj.jpg') 53 | 54 | const createdId = res.data.createProfile.id 55 | 56 | res = await context.helper.apolloClient.client.query({ 57 | // language=GraphQL 58 | query: gql`{ 59 | allProfiles{ 60 | id 61 | } 62 | }` 63 | }) 64 | t.falsy(res.errors) 65 | t.truthy(res.data.allProfiles) 66 | t.is(res.data.allProfiles.length, 1) 67 | t.is(res.data.allProfiles[0].id, createdId) 68 | }) 69 | 70 | test.serial(`hould get a Profile by email (${context.testNote})`, async t => { 71 | let res = await context.helper.apolloClient.client.query({ 72 | // language=GraphQL 73 | query: gql`{ 74 | profile (email: "jordan@example.com") { 75 | id, 76 | email, 77 | displayName, 78 | biography, 79 | avatarUrl 80 | } 81 | } 82 | ` 83 | }) 84 | 85 | t.falsy(res.errors) 86 | t.truthy(res.data.profile) 87 | t.truthy(res.data.profile.id) 88 | t.deepEqual(res.data.profile.email, 'jordan@example.com') 89 | t.deepEqual(res.data.profile.displayName, 'Michael Jordan') 90 | t.deepEqual(res.data.profile.biography, 'Nr #23!') 91 | t.deepEqual(res.data.profile.avatarUrl, 'http://example.com/mj.jpg') 92 | }) 93 | 94 | test.serial(`should update a Profile (${context.testNote})`, async t => { 95 | let res = await context.helper.apolloClient.client.query({ 96 | // language=GraphQL 97 | query: gql`{ 98 | allProfiles{ 99 | id 100 | } 101 | }` 102 | }) 103 | 104 | const profileId = res.data.allProfiles[0].id 105 | 106 | t.falsy(res.errors) 107 | t.truthy(profileId) 108 | 109 | res = await context.helper.apolloClient.client.mutate({ 110 | // language=GraphQL 111 | mutation: gql` 112 | mutation { 113 | updateProfile ( 114 | id: "${profileId}", 115 | email: "mj@example.com", 116 | displayName: "Michael Jordan", 117 | biography:"Nr #23!", 118 | avatarUrl:"http://example.com/mj.jpg" 119 | ) { 120 | id, 121 | email, 122 | displayName, 123 | biography, 124 | avatarUrl 125 | } 126 | } 127 | ` 128 | }) 129 | 130 | t.falsy(res.errors) 131 | t.truthy(res.data.updateProfile) 132 | t.truthy(res.data.updateProfile.id) 133 | t.deepEqual(res.data.updateProfile.email, 'mj@example.com') 134 | t.deepEqual(res.data.updateProfile.displayName, 'Michael Jordan') 135 | t.deepEqual(res.data.updateProfile.biography, 'Nr #23!') 136 | t.deepEqual(res.data.updateProfile.avatarUrl, 'http://example.com/mj.jpg') 137 | 138 | res = await context.helper.apolloClient.client.query({ 139 | // language=GraphQL 140 | query: gql`{ 141 | allProfiles{ 142 | id, 143 | email, 144 | displayName, 145 | biography, 146 | avatarUrl 147 | } 148 | }` 149 | }) 150 | 151 | t.falsy(res.errors) 152 | t.truthy(res.data.allProfiles) 153 | t.is(res.data.allProfiles.length, 1) 154 | t.is(res.data.allProfiles[0].id, profileId) 155 | t.deepEqual(res.data.allProfiles[0].email, 'mj@example.com') 156 | t.deepEqual(res.data.allProfiles[0].displayName, 'Michael Jordan') 157 | t.deepEqual(res.data.allProfiles[0].biography, 'Nr #23!') 158 | t.deepEqual(res.data.allProfiles[0].avatarUrl, 'http://example.com/mj.jpg') 159 | }) 160 | 161 | test.serial(`should delete a Profile (${context.testNote})`, async t => { 162 | let res = await context.helper.apolloClient.client.query({ 163 | // language=GraphQL 164 | query: gql`{ 165 | allProfiles{ 166 | id 167 | } 168 | }` 169 | }) 170 | 171 | const profileId = res.data.allProfiles[0].id 172 | 173 | t.falsy(res.errors) 174 | t.truthy(profileId) 175 | 176 | res = await context.helper.apolloClient.client.mutate({ 177 | // language=GraphQL 178 | mutation: gql` 179 | mutation { 180 | deleteProfile ( 181 | id: "${profileId}" 182 | ) 183 | } 184 | ` 185 | }) 186 | 187 | t.falsy(res.errors) 188 | t.deepEqual(res.data.deleteProfile.true) 189 | 190 | res = await context.helper.apolloClient.client.query({ 191 | // language=GraphQL 192 | query: gql`{ 193 | allProfiles { 194 | id, 195 | email, 196 | displayName, 197 | biography, 198 | avatarUrl 199 | } 200 | }` 201 | }) 202 | 203 | t.falsy(res.errors) 204 | t.truthy(res.data.allProfiles) 205 | t.is(res.data.allProfiles.length, 0) 206 | }) 207 | 208 | test.serial(`should return empty list when no Memes created yet (${context.testNote})`, async t => { 209 | const res = await context.helper.apolloClient.client.query({ 210 | // language=GraphQL 211 | query: gql`{ 212 | allMemes { 213 | id 214 | } 215 | }` 216 | }) 217 | 218 | t.falsy(res.errors) 219 | t.deepEqual(res.data, { allMemes: [] }) 220 | }) 221 | 222 | test.serial(`should create a Profile and a Meme (${context.testNote})`, async t => { 223 | let res = await context.helper.apolloClient.client.mutate({ 224 | // language=GraphQL 225 | mutation: gql` 226 | mutation { 227 | createProfile ( 228 | email: "jordan@example.com", 229 | displayName: "Michael Jordan", 230 | biography:"Nr #23!", 231 | avatarUrl:"http://example.com/mj.jpg" 232 | ) { 233 | id, 234 | email, 235 | displayName, 236 | biography, 237 | avatarUrl 238 | } 239 | } 240 | ` 241 | }) 242 | 243 | t.falsy(res.errors) 244 | t.truthy(res.data.createProfile) 245 | t.truthy(res.data.createProfile.id) 246 | 247 | const profileId = res.data.createProfile.id 248 | 249 | res = await context.helper.apolloClient.client.mutate({ 250 | // language=GraphQL 251 | mutation: gql` 252 | mutation { 253 | createMeme ( 254 | ownerId: "${profileId}", 255 | photoUrl:"http://example.com/meme.jpg" 256 | ) { 257 | id, 258 | photoUrl, 259 | ownerId 260 | } 261 | } 262 | ` 263 | }) 264 | 265 | t.log(res) 266 | 267 | t.falsy(res.errors) 268 | t.truthy(res.data.createMeme) 269 | t.truthy(res.data.createMeme.id) 270 | t.is(res.data.createMeme.ownerId, profileId) 271 | t.is(res.data.createMeme.photoUrl, 'http://example.com/meme.jpg') 272 | 273 | const memeId = res.data.createMeme.id 274 | 275 | res = await context.helper.apolloClient.client.query({ 276 | // language=GraphQL 277 | query: gql`{ 278 | allMemes { 279 | id, 280 | photoUrl, 281 | ownerId 282 | } 283 | }` 284 | }) 285 | 286 | t.falsy(res.errors) 287 | t.truthy(res.data.allMemes) 288 | t.is(res.data.allMemes.length, 1) 289 | t.is(res.data.allMemes[0].id, memeId) 290 | t.is(res.data.allMemes[0].ownerId, profileId) 291 | t.is(res.data.allMemes[0].photoUrl, 'http://example.com/meme.jpg') 292 | 293 | res = await context.helper.apolloClient.client.query({ 294 | // language=GraphQL 295 | query: gql`{ 296 | profile (email: "jordan@example.com") { 297 | id, 298 | email, 299 | displayName, 300 | biography, 301 | avatarUrl, 302 | memes{ 303 | id, 304 | photoUrl, 305 | ownerId 306 | } 307 | } 308 | } 309 | ` 310 | }) 311 | 312 | t.falsy(res.errors) 313 | t.truthy(res.data.profile) 314 | t.truthy(res.data.profile.id) 315 | t.is(res.data.profile.memes[0].id, memeId) 316 | t.is(res.data.profile.memes[0].ownerId, profileId) 317 | t.is(res.data.profile.memes[0].photoUrl, 'http://example.com/meme.jpg') 318 | }) 319 | } 320 | -------------------------------------------------------------------------------- /integration_test/datasyncservice.smoke.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const axios = require('axios') 3 | const DataSyncService = require('../DataSyncService') 4 | const config = require('../server/config') 5 | 6 | test.serial('The DataSyncService class successfully initializes and starts', async (t) => { 7 | config.server.port = 0 // specifying port 0 gives us a random port number to make sure it doesn't conflict with anything 8 | t.plan(2) 9 | const syncService = new DataSyncService(config) 10 | await syncService.initialize() 11 | await syncService.start() 12 | 13 | let { port } = syncService.app.server.address() 14 | 15 | const response = await axios.get(`http://localhost:${port}/healthz`) 16 | t.deepEqual(response.status, 200) 17 | t.pass() 18 | }) 19 | -------------------------------------------------------------------------------- /integration_test/helper.js: -------------------------------------------------------------------------------- 1 | const PGPubsub = require('pg-pubsub') 2 | const pg = require('pg') 3 | const RestartableSyncService = require('./util/restartableSyncService') 4 | const TestApolloClient = require('./util/testApolloClient') 5 | 6 | let config = require('../server/config') 7 | let { postgresConfig } = config 8 | 9 | function Helper () { 10 | this.pubsubInstance = new PGPubsub({ 11 | user: postgresConfig.username, 12 | host: postgresConfig.options.host, 13 | database: postgresConfig.database, 14 | password: postgresConfig.password, 15 | port: postgresConfig.options.port 16 | }) 17 | 18 | this.syncService = new RestartableSyncService(config) 19 | 20 | this.models = require('@aerogear/data-sync-gql-core').models(postgresConfig) 21 | this.sequelize = this.models.sequelize 22 | 23 | // see http://docs.sequelizejs.com/class/lib/query-interface.js~QueryInterface.html 24 | this.qi = this.sequelize.queryInterface 25 | 26 | this.initialize = async () => { 27 | this.resetApolloClient() 28 | await this.syncService.initialize() 29 | await this.syncService.start() 30 | } 31 | 32 | this.resetApolloClient = (headers) => { 33 | this.apolloClient = new TestApolloClient('localhost:8000', headers) 34 | } 35 | 36 | this.deleteConfig = async () => { 37 | await this.qi.bulkDelete('DataSources') 38 | await this.qi.bulkDelete('GraphQLSchemas') 39 | await this.qi.bulkDelete('Subscriptions') 40 | await this.qi.bulkDelete('Resolvers') 41 | } 42 | 43 | this.feedConfig = async (configFile) => { 44 | const config = require(`./config/${configFile}`) 45 | if (!config.description) { 46 | throw new Error(`Please define the description in file ./config/${configFile}`) 47 | } 48 | await config.up(this.qi, this.sequelize) 49 | } 50 | 51 | this.triggerReload = async () => { 52 | this.pubsubInstance.publish('aerogear-data-sync-config', {}) 53 | // sleep 1000 ms so that sync server can pick up the changes 54 | await new Promise(resolve => setTimeout(resolve, 1000)) 55 | } 56 | 57 | this.listenForPubsubNotification = (topic) => { 58 | return new Promise((resolve, reject) => { 59 | this.pubsubInstance.addChannel(topic) 60 | this.pubsubInstance.on(topic, resolve) 61 | setTimeout(reject.bind(null, new Error(`Timed out while listening for pubsub message on topic ${topic}`)), 3000) 62 | }) 63 | } 64 | 65 | this.cleanMemeolistDatabase = async (t) => { 66 | t.log('Going to prepare memeolist database for the integration tests') 67 | 68 | const { Client } = pg 69 | 70 | const memeoListDbHost = process.env.MEMEOLIST_DB_HOST || '127.0.0.1' 71 | const memeoListDbPort = process.env.MEMEOLIST_DB_PORT || '15432' 72 | 73 | const client = new Client({ 74 | user: 'postgresql', 75 | password: 'postgres', 76 | database: 'memeolist_db', 77 | host: memeoListDbHost, 78 | port: memeoListDbPort 79 | }) 80 | 81 | try { 82 | await client.connect() 83 | await client.query('SELECT 1') 84 | } catch (err) { 85 | t.log('Unable to connect memeolist database for preparing it for the integration tests') 86 | throw err 87 | } 88 | 89 | try { 90 | // language=SQL 91 | await client.query(` 92 | DROP TABLE IF EXISTS "Meme"; 93 | DROP TABLE IF EXISTS "Profile"; 94 | 95 | CREATE TABLE "Profile" ( 96 | "id" SERIAL PRIMARY KEY NOT NULL, 97 | "email" CHARACTER VARYING(500) NOT NULL, 98 | "displayName" CHARACTER VARYING(500) NOT NULL, 99 | "biography" CHARACTER VARYING(500) NOT NULL, 100 | "avatarUrl" CHARACTER VARYING(500) NOT NULL 101 | ); 102 | 103 | CREATE TABLE "Meme" ( 104 | "id" SERIAL PRIMARY KEY NOT NULL, 105 | "photoUrl" CHARACTER VARYING(500) NOT NULL, 106 | "ownerId" INTEGER 107 | ); 108 | `) 109 | } catch (err) { 110 | t.log('Error while preparing memeolist database for the integration tests') 111 | throw err 112 | } 113 | 114 | await client.end() 115 | } 116 | } 117 | 118 | module.exports = Helper 119 | -------------------------------------------------------------------------------- /integration_test/inmem.integration.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const base = require('./datasource.integration.test.base') 3 | 4 | const context = { 5 | helper: undefined, 6 | testNote: 'noauth, inmem' 7 | } 8 | 9 | test.before(async t => { 10 | const Helper = require('./helper') 11 | const helper = new Helper() 12 | 13 | await helper.initialize() 14 | 15 | // delete the all the config 1-time before starting the tests 16 | await helper.deleteConfig() 17 | await helper.feedConfig('complete.inmem.valid.memeo') 18 | await helper.triggerReload() 19 | 20 | context.helper = helper 21 | }) 22 | 23 | base(context) 24 | -------------------------------------------------------------------------------- /integration_test/postgres.integration.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const base = require('./datasource.integration.test.base') 3 | 4 | const context = { 5 | helper: undefined, 6 | testNote: 'noauth, postgres' 7 | } 8 | 9 | test.before(async t => { 10 | const Helper = require('./helper') 11 | const helper = new Helper() 12 | 13 | await helper.initialize() 14 | await helper.cleanMemeolistDatabase(t) 15 | 16 | // delete the all the config 1-time before starting the tests 17 | await helper.deleteConfig() 18 | await helper.feedConfig('complete.postgres.valid.memeo') 19 | await helper.triggerReload() 20 | 21 | context.helper = helper 22 | }) 23 | 24 | base(context) 25 | -------------------------------------------------------------------------------- /integration_test/resolver.publisers.integration.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const Helper = require('./helper') 3 | const TestApolloClient = require('./util/testApolloClient') 4 | const gql = require('graphql-tag') 5 | 6 | const context = { 7 | apolloClient: null, 8 | helper: null 9 | } 10 | 11 | test.before(async t => { 12 | context.apolloClient = new TestApolloClient() 13 | const helper = new Helper() 14 | 15 | await helper.initialize() 16 | 17 | // delete the all the config 1-time before starting the tests 18 | await helper.deleteConfig() 19 | await helper.feedConfig('complete.postgres.valid.memeo.js') 20 | await helper.syncService.restart() 21 | context.helper = helper 22 | }) 23 | 24 | test.serial('resolvers can be configured to publish notifications to custom topics', async (t) => { 25 | const { apolloClient, helper } = context 26 | const customTopic = 'myCustomTopic' 27 | 28 | const pubsubNotification = helper.listenForPubsubNotification(customTopic) 29 | 30 | const publish = JSON.stringify({ 31 | topic: customTopic, 32 | payload: `{ 33 | "memeAdded": {{ toJSON context.result }} 34 | }` 35 | }) 36 | 37 | await helper.models.Resolver.update({ publish }, { where: { field: 'createMeme' } }) 38 | 39 | await helper.syncService.restart() 40 | 41 | await apolloClient.client.mutate({ 42 | // language=GraphQL 43 | mutation: gql` 44 | mutation { 45 | createMeme ( 46 | ownerId: "1", 47 | photoUrl:"https://example.com/meme.jpg" 48 | ) { 49 | id, 50 | photoUrl, 51 | ownerId 52 | } 53 | } 54 | ` 55 | }) 56 | 57 | const pubsubResult = await pubsubNotification 58 | t.truthy(pubsubResult) 59 | }) 60 | 61 | test.serial('resolvers can be configured to publish custom payloads to the pubsub layer', async (t) => { 62 | const { apolloClient, helper } = context 63 | const customTopic = 'myCustomTopic' 64 | 65 | const pubsubNotification = helper.listenForPubsubNotification(customTopic) 66 | 67 | const publish = JSON.stringify({ 68 | topic: customTopic, 69 | payload: `{ 70 | "memeAdded": {{ toJSON context.result }}, 71 | "someCustomField": "some custom value" 72 | }` 73 | }) 74 | 75 | await helper.models.Resolver.update({ publish }, { where: { field: 'createMeme' } }) 76 | 77 | await helper.syncService.restart() 78 | 79 | await apolloClient.client.mutate({ 80 | // language=GraphQL 81 | mutation: gql` 82 | mutation { 83 | createMeme ( 84 | ownerId: "1", 85 | photoUrl:"https://example.com/meme.jpg" 86 | ) { 87 | id, 88 | photoUrl, 89 | ownerId 90 | } 91 | } 92 | ` 93 | }) 94 | 95 | let result = await pubsubNotification 96 | t.log(result) 97 | 98 | // asserting the main test case that custom fields can be defined in payload 99 | t.truthy(result.someCustomField) 100 | t.deepEqual(result.someCustomField, 'some custom value') 101 | }) 102 | -------------------------------------------------------------------------------- /integration_test/subscriptions.hot.reload.integration.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const Helper = require('./helper') 3 | const TestApolloClient = require('./util/testApolloClient') 4 | const gql = require('graphql-tag') 5 | 6 | const context = { 7 | apolloClient: null, 8 | helper: null 9 | } 10 | 11 | test.before(async t => { 12 | context.apolloClient = new TestApolloClient() 13 | const helper = new Helper() 14 | 15 | await helper.initialize() 16 | 17 | // delete the all the config 1-time before starting the tests 18 | await helper.deleteConfig() 19 | await helper.feedConfig('complete.postgres.valid.memeo.js') 20 | await helper.cleanMemeolistDatabase(t) 21 | await helper.syncService.restart() 22 | context.helper = helper 23 | }) 24 | 25 | test.serial('Newly added subscriptions should be hot reloaded', async (t) => { 26 | const { helper, apolloClient } = context 27 | 28 | const newSchemaString = ` 29 | 30 | type Profile { 31 | id: ID! @isUnique 32 | email: String! @isUnique 33 | displayName: String! 34 | biography: String! 35 | avatarUrl: String! 36 | memes: [Meme]! 37 | } 38 | 39 | type Meme { 40 | id: ID! @isUnique 41 | photoUrl: String! 42 | ownerId: String! 43 | } 44 | 45 | type Query { 46 | allProfiles:[Profile!]! 47 | profile(email: String!):Profile 48 | allMemes:[Meme!]! 49 | } 50 | 51 | type Mutation { 52 | createProfile(email: String!, displayName: String!, biography: String!, avatarUrl: String!):Profile! 53 | updateProfile(id: ID!, email: String!, displayName: String!, biography: String!, avatarUrl: String!):Profile 54 | deleteProfile(id: ID!):Boolean! 55 | createMeme(ownerId: String!, photoUrl: String!):Meme! 56 | } 57 | 58 | type Subscription { 59 | memeAdded(photoUrl: String):Meme! 60 | profileAdded:Profile 61 | } 62 | 63 | ` 64 | 65 | const newSubscription = { 66 | type: 'Subscription', 67 | field: 'profileAdded', 68 | GraphQLSchemaId: 2 69 | } 70 | 71 | const publish = newSubscription.field 72 | 73 | // update existing schema, subscriptions and resolvers to enable a 'profileAdded' subscription 74 | await helper.models.GraphQLSchema.update({ schema: newSchemaString }, { where: { name: 'default' } }) 75 | await helper.models.Subscription.build(newSubscription).save() 76 | await helper.models.Resolver.update({ publish }, { where: { field: 'createProfile' } }) 77 | 78 | // trigger the hot reload 79 | await context.helper.triggerReload() 80 | 81 | // let's try test that our new subscription works 82 | let subscription = apolloClient.subscribe(gql` 83 | subscription profileAdded { 84 | profileAdded { 85 | id, 86 | email, 87 | displayName, 88 | biography, 89 | avatarUrl 90 | } 91 | } 92 | `) 93 | 94 | await apolloClient.client.mutate({ 95 | // language=GraphQL 96 | mutation: gql` 97 | mutation { 98 | createProfile ( 99 | email: "jordan@example.com", 100 | displayName: "Michael Jordan", 101 | biography:"Nr #23!", 102 | avatarUrl:"http://example.com/mj.jpg" 103 | ) { 104 | id, 105 | email, 106 | displayName, 107 | biography, 108 | avatarUrl 109 | } 110 | } 111 | ` 112 | }) 113 | 114 | let result = await subscription 115 | 116 | t.truthy(result) 117 | t.truthy(result.data.profileAdded) 118 | }) 119 | -------------------------------------------------------------------------------- /integration_test/subscriptions.inmem.integration.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const Helper = require('./helper') 3 | const TestApolloClient = require('./util/testApolloClient') 4 | const base = require('./subscriptions.integration.test.base') 5 | 6 | const context = { 7 | apolloClient: null, 8 | helper: null 9 | } 10 | 11 | test.before(async t => { 12 | context.apolloClient = new TestApolloClient() 13 | const helper = new Helper() 14 | 15 | await helper.initialize() 16 | 17 | // delete the all the config 1-time before starting the tests 18 | await helper.deleteConfig() 19 | await helper.feedConfig('complete.inmem.valid.memeo.js') 20 | await helper.syncService.restart() 21 | context.helper = helper 22 | }) 23 | 24 | base(context) 25 | -------------------------------------------------------------------------------- /integration_test/subscriptions.integration.test.base.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const gql = require('graphql-tag') 3 | const RestartableSyncService = require('./util/restartableSyncService') 4 | const TestApolloClient = require('./util/testApolloClient') 5 | const config = require('../server/config') 6 | 7 | module.exports = (context) => { 8 | test.serial('clients should receive updates from subscription resolvers', async (t) => { 9 | const { apolloClient } = context 10 | 11 | let subscription = apolloClient.subscribe(gql` 12 | subscription memeAdded { 13 | memeAdded { 14 | photoUrl, 15 | ownerId, 16 | id 17 | } 18 | } 19 | `) 20 | 21 | const profileId = '1' 22 | const photoUrl = 'https://example.com/meme.jpg' 23 | 24 | await apolloClient.client.mutate({ 25 | // language=GraphQL 26 | mutation: gql` 27 | mutation { 28 | createMeme ( 29 | ownerId: "${profileId}", 30 | photoUrl:"${photoUrl}" 31 | ) { 32 | id, 33 | photoUrl, 34 | ownerId 35 | } 36 | } 37 | ` 38 | }) 39 | 40 | let result = await subscription 41 | 42 | t.truthy(result.data.memeAdded) 43 | t.deepEqual(result.data.memeAdded.photoUrl, photoUrl) 44 | t.deepEqual(result.data.memeAdded.ownerId, profileId) 45 | }) 46 | 47 | test.serial('subscriptions work across server instances', async (t) => { 48 | const serviceA = new RestartableSyncService({ ...config, server: { port: 8001 } }) 49 | const serviceB = new RestartableSyncService({ ...config, server: { port: 8002 } }) 50 | 51 | const clientA = new TestApolloClient('localhost:8001') 52 | const clientB = new TestApolloClient('localhost:8002') 53 | 54 | await serviceA.initialize() 55 | await serviceB.initialize() 56 | 57 | await serviceA.start() 58 | await serviceB.start() 59 | 60 | let subscription = clientA.subscribe(gql` 61 | subscription memeAdded { 62 | memeAdded { 63 | photoUrl, 64 | ownerId, 65 | id 66 | } 67 | } 68 | `) 69 | 70 | const profileId = '1' 71 | const photoUrl = 'https://example.com/meme.jpg' 72 | 73 | await clientB.client.mutate({ 74 | // language=GraphQL 75 | mutation: gql` 76 | mutation { 77 | createMeme ( 78 | ownerId: "${profileId}", 79 | photoUrl:"${photoUrl}" 80 | ) { 81 | id, 82 | photoUrl, 83 | ownerId 84 | } 85 | } 86 | ` 87 | }) 88 | 89 | let result = await subscription 90 | 91 | t.truthy(result.data.memeAdded) 92 | t.deepEqual(result.data.memeAdded.photoUrl, photoUrl) 93 | t.deepEqual(result.data.memeAdded.ownerId, profileId) 94 | }) 95 | 96 | test.serial('subscriptions can use a filtering mechanism', async (t) => { 97 | const { apolloClient, helper } = context 98 | 99 | const filter = { 100 | 'eq': ['$payload.memeAdded.photoUrl', '$variables.photoUrl'] 101 | } 102 | 103 | const testPhotoUrl = 'http://testing.com' 104 | const profileId = '1' 105 | 106 | await helper.models.Subscription.update({ filter }, { where: { field: 'memeAdded' } }) 107 | 108 | await helper.syncService.restart() 109 | 110 | let subscription = apolloClient.subscribe(gql` 111 | subscription memeAdded { 112 | memeAdded(photoUrl: "${testPhotoUrl}") { 113 | photoUrl, 114 | ownerId, 115 | id 116 | } 117 | } 118 | `) 119 | 120 | await apolloClient.client.mutate({ 121 | // language=GraphQL 122 | mutation: gql` 123 | mutation { 124 | createMeme ( 125 | ownerId: "${profileId}", 126 | photoUrl:"${testPhotoUrl}" 127 | ) { 128 | id, 129 | photoUrl, 130 | ownerId 131 | } 132 | } 133 | ` 134 | }) 135 | 136 | let result = await subscription 137 | 138 | t.truthy(result.data.memeAdded) 139 | t.deepEqual(result.data.memeAdded.photoUrl, testPhotoUrl) 140 | t.deepEqual(result.data.memeAdded.ownerId, profileId) 141 | }) 142 | 143 | test.serial('subscriptions will not receive updates when filters evaluate false', async (t) => { 144 | const { apolloClient, helper } = context 145 | 146 | const filter = { 147 | 'eq': ['$payload.memeAdded.photoUrl', '$variables.photoUrl'] 148 | } 149 | 150 | const payloadUrl = 'http://someurl.com' 151 | const variablesUrl = 'http://someotherurl.com' 152 | 153 | const profileId = '1' 154 | 155 | await helper.models.Subscription.update({ filter }, { where: { field: 'memeAdded' } }) 156 | 157 | await helper.syncService.restart() 158 | 159 | let subscription = apolloClient.subscribe(gql` 160 | subscription memeAdded { 161 | memeAdded(photoUrl: "${variablesUrl}") { 162 | photoUrl, 163 | ownerId, 164 | id 165 | } 166 | } 167 | `) 168 | 169 | await apolloClient.client.mutate({ 170 | // language=GraphQL 171 | mutation: gql` 172 | mutation { 173 | createMeme ( 174 | ownerId: "${profileId}", 175 | photoUrl:"${payloadUrl}" 176 | ) { 177 | id, 178 | photoUrl, 179 | ownerId 180 | } 181 | } 182 | ` 183 | }) 184 | 185 | await t.throws(async () => { 186 | await subscription 187 | }) 188 | }) 189 | } 190 | -------------------------------------------------------------------------------- /integration_test/subscriptions.postgres.integration.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const Helper = require('./helper') 3 | const TestApolloClient = require('./util/testApolloClient') 4 | const base = require('./subscriptions.integration.test.base') 5 | 6 | const context = { 7 | apolloClient: null, 8 | helper: null 9 | } 10 | 11 | test.before(async t => { 12 | context.apolloClient = new TestApolloClient() 13 | const helper = new Helper() 14 | 15 | await helper.initialize() 16 | 17 | // delete the all the config 1-time before starting the tests 18 | await helper.deleteConfig() 19 | await helper.feedConfig('complete.postgres.valid.memeo.js') 20 | await helper.cleanMemeolistDatabase(t) 21 | await helper.syncService.restart() 22 | context.helper = helper 23 | }) 24 | 25 | base(context) 26 | -------------------------------------------------------------------------------- /integration_test/util/auth.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | 3 | async function authenticateKeycloak (config, username, password) { 4 | const res = await axios({ 5 | method: 'post', 6 | url: `${config['auth-server-url']}/realms/${config.realm}/protocol/openid-connect/token`, 7 | data: `client_id=${config.resource}&username=${username}&password=${password}&grant_type=password` 8 | }) 9 | return { Authorization: `Bearer ${res.data['access_token']}` } 10 | } 11 | 12 | module.exports = { authenticateKeycloak } 13 | -------------------------------------------------------------------------------- /integration_test/util/restartableSyncService.js: -------------------------------------------------------------------------------- 1 | const DataSyncService = require('../../DataSyncService') 2 | const stoppable = require('stoppable') 3 | 4 | // There's a bug somewhere in the server initialization/start sequence 5 | // Even though we await service.initialize() some things aren't always ready 6 | // Like underlying pubsub layer etc. 7 | const RESTART_DELAY = 200 8 | 9 | class RestartableSyncService extends DataSyncService { 10 | async start () { 11 | this.app.server = stoppable(this.app.server, 0) 12 | await this.app.server.listen(this.port) 13 | this.log.info(`Server is now running on http://localhost:${this.port}`) 14 | await new Promise(resolve => setTimeout(resolve, RESTART_DELAY)) 15 | } 16 | 17 | async restart () { 18 | this.log.info('restarting server') 19 | await this.app.cleanup() 20 | this.app.server.stop() 21 | await this.initialize() 22 | await this.start() 23 | await new Promise(resolve => setTimeout(resolve, RESTART_DELAY)) 24 | } 25 | } 26 | 27 | module.exports = RestartableSyncService 28 | -------------------------------------------------------------------------------- /integration_test/util/testApolloClient.js: -------------------------------------------------------------------------------- 1 | const { split } = require('apollo-link') 2 | const { HttpLink } = require('apollo-link-http') 3 | const { WebSocketLink } = require('apollo-link-ws') 4 | const { getMainDefinition } = require('apollo-utilities') 5 | const fetch = require('node-fetch') 6 | const ws = require('ws') 7 | const { ApolloClient } = require('apollo-client') 8 | const { InMemoryCache } = require('apollo-cache-inmemory') 9 | 10 | class TestApolloClient { 11 | constructor (host = 'localhost:8000', authHeaders) { 12 | this.client = createApolloClient(host, authHeaders) 13 | } 14 | 15 | subscribe (query, timeout = 3000) { 16 | return new Promise((resolve, reject) => { 17 | this.client.subscribe({ 18 | query: query 19 | }).subscribe({ 20 | next: resolve, 21 | error: reject 22 | }) 23 | setTimeout(reject.bind(null, new Error('timed out while waiting for subscription result')), timeout) 24 | }) 25 | } 26 | } 27 | 28 | module.exports = TestApolloClient 29 | 30 | function createApolloClient (host, authHeaders) { 31 | // Create an http link: 32 | const httpLinkConfig = { 33 | uri: `http://${host}/graphql`, 34 | fetch: fetch, 35 | headers: authHeaders 36 | } 37 | 38 | const httpLink = new HttpLink(httpLinkConfig) 39 | 40 | // Create a WebSocket link: 41 | const wsLink = new WebSocketLink({ 42 | uri: `ws://${host}/graphql`, 43 | options: { 44 | reconnect: true 45 | }, 46 | webSocketImpl: ws 47 | }) 48 | 49 | // using the ability to split links, you can send data to each link 50 | // depending on what kind of operation is being sent 51 | const link = split( 52 | // split based on operation type 53 | ({ query }) => { 54 | const { kind, operation } = getMainDefinition(query) 55 | return kind === 'OperationDefinition' && operation === 'subscription' 56 | }, 57 | wsLink, 58 | httpLink 59 | ) 60 | 61 | return new ApolloClient({ 62 | link: link, 63 | cache: new InMemoryCache(), 64 | defaultOptions: { 65 | query: { 66 | fetchPolicy: 'no-cache' 67 | }, 68 | watchQuery: { 69 | fetchPolicy: 'no-cache' 70 | } 71 | } 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /k8s_templates/data-sources.json: -------------------------------------------------------------------------------- 1 | { 2 | "p1": { 3 | "type": "postgres", 4 | "config": { 5 | "user": "datasync", 6 | "password": "datasync", 7 | "database": "datasync", 8 | "host": "postgres", 9 | "dialect": "postgres" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /k8s_templates/datasync_deployment.yml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | run: datasync 6 | name: datasync 7 | spec: 8 | replicas: 1 9 | strategy: 10 | type: Recreate 11 | selector: 12 | matchLabels: 13 | run: datasync 14 | template: 15 | metadata: 16 | creationTimestamp: null 17 | labels: 18 | run: datasync 19 | spec: 20 | containers: 21 | - env: 22 | - name: SCHEMA_FILE 23 | value: /etc/data-sync/config/schema.graphql 24 | - name: DATA_SOURCES_FILE 25 | value: /etc/data-sync/config/data-sources.json 26 | - name: RESOLVER_MAPPINGS_FILE 27 | value: /etc/data-sync/config/resolver-mappings.json 28 | - name: QUERY_FILE 29 | value: /etc/data-sync/config/query.graphql 30 | image: docker.io/davidmartin/data-sync-server:latest 31 | imagePullPolicy: IfNotPresent 32 | name: datasync 33 | ports: 34 | - containerPort: 8000 35 | protocol: TCP 36 | volumeMounts: 37 | - mountPath: "/etc/data-sync/config" 38 | name: data-sync-config 39 | readOnly: true 40 | volumes: 41 | - name: data-sync-config 42 | secret: 43 | secretName: data-sync-config 44 | -------------------------------------------------------------------------------- /k8s_templates/datasync_route.yml: -------------------------------------------------------------------------------- 1 | apiVersion: route.openshift.io/v1 2 | kind: Route 3 | metadata: 4 | annotations: 5 | openshift.io/host.generated: "true" 6 | creationTimestamp: 2018-06-16T10:45:54Z 7 | labels: 8 | run: datasync 9 | name: datasync 10 | namespace: dm-myproject-4 11 | resourceVersion: "662439016" 12 | selfLink: /apis/route.openshift.io/v1/namespaces/dm-myproject-4/routes/datasync 13 | uid: 71b6b8a8-7152-11e8-82bb-02ec8e61afcf 14 | spec: 15 | host: datasync-dm-myproject-4.193b.starter-ca-central-1.openshiftapps.com 16 | tls: 17 | termination: edge 18 | to: 19 | kind: Service 20 | name: datasync 21 | weight: 100 22 | wildcardPolicy: None 23 | status: 24 | ingress: 25 | - conditions: 26 | - lastTransitionTime: 2018-06-16T10:45:54Z 27 | status: "True" 28 | type: Admitted 29 | host: datasync-dm-myproject-4.193b.starter-ca-central-1.openshiftapps.com 30 | routerCanonicalHostname: elb.193b.starter-ca-central-1.openshiftapps.com 31 | routerName: router 32 | wildcardPolicy: None 33 | -------------------------------------------------------------------------------- /k8s_templates/datasync_service.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | run: datasync 6 | name: datasync 7 | spec: 8 | ports: 9 | - port: 8000 10 | protocol: TCP 11 | targetPort: 8000 12 | selector: 13 | run: datasync 14 | sessionAffinity: None 15 | type: ClusterIP -------------------------------------------------------------------------------- /k8s_templates/postgres_claim.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | metadata: 4 | name: postgres-data 5 | spec: 6 | accessModes: 7 | - ReadWriteOnce 8 | resources: 9 | requests: 10 | storage: 1Gi -------------------------------------------------------------------------------- /k8s_templates/postgres_deployment.yml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | run: postgres 6 | name: postgres 7 | spec: 8 | replicas: 1 9 | strategy: 10 | type: Recreate 11 | selector: 12 | matchLabels: 13 | run: postgres 14 | template: 15 | metadata: 16 | creationTimestamp: null 17 | labels: 18 | run: postgres 19 | spec: 20 | containers: 21 | - env: 22 | - name: POSTGRESQL_USER 23 | value: datasync 24 | - name: POSTGRESQL_PASSWORD 25 | value: datasync 26 | - name: POSTGRESQL_DATABASE 27 | value: datasync 28 | image: centos/postgresql-96-centos7:latest 29 | imagePullPolicy: IfNotPresent 30 | name: postgres 31 | ports: 32 | - containerPort: 5432 33 | protocol: TCP 34 | volumeMounts: 35 | - mountPath: "/var/lib/pgsql/data" 36 | name: postgres-data 37 | volumeMounts: 38 | - mountPath: "/var/lib/pgsql/sql" 39 | name: postgres-sql 40 | volumes: 41 | - name: postgres-data 42 | persistentVolumeClaim: 43 | claimName: postgres-data 44 | - name: postgres-sql 45 | configMap: 46 | name: postgres-sql 47 | -------------------------------------------------------------------------------- /k8s_templates/postgres_service.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | run: postgres 6 | name: postgres 7 | spec: 8 | ports: 9 | - port: 5432 10 | protocol: TCP 11 | targetPort: 5432 12 | selector: 13 | run: postgres 14 | sessionAffinity: None 15 | type: ClusterIP -------------------------------------------------------------------------------- /keycloak/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | keycloak: 5 | image: jboss/keycloak:3.4.3.Final 6 | ports: 7 | - "8080:8080" 8 | environment: 9 | DB_VENDOR: h2 10 | KEYCLOAK_USER: admin 11 | KEYCLOAK_PASSWORD: admin 12 | 13 | postgres: 14 | image: postgres:9.6 15 | ports: 16 | - "5432:5432" 17 | environment: 18 | POSTGRES_PASSWORD: postgres 19 | POSTGRES_USER: postgresql 20 | POSTGRES_DB: aerogear_data_sync_db 21 | 22 | postgres_memeo: 23 | image: postgres:9.6 24 | ports: 25 | - "15432:5432" 26 | environment: 27 | POSTGRES_PASSWORD: postgres 28 | POSTGRES_USER: postgresql 29 | POSTGRES_DB: memeolist_db 30 | volumes: 31 | - ./examples:/tmp/examples 32 | 33 | -------------------------------------------------------------------------------- /keycloak/keycloak.json: -------------------------------------------------------------------------------- 1 | { 2 | "realm": "Memeolist", 3 | "auth-server-url": "https://keycloak.security.feedhenry.org/auth", 4 | "ssl-required": "external", 5 | "resource": "sync-server", 6 | "public-client": true 7 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "data-sync-server", 3 | "version": "0.1.0", 4 | "description": "GraphQL based server for syncing data between clients", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/aerogear/data-sync-server.git" 8 | }, 9 | "keywords": [ 10 | "graphql", 11 | "datasync", 12 | "mobile" 13 | ], 14 | "author": "davmarti@redhat.com", 15 | "license": "Apache-2.0", 16 | "bugs": { 17 | "url": "https://github.com/aerogear/data-sync-server/issues" 18 | }, 19 | "homepage": "https://github.com/aerogear/data-sync-server#readme", 20 | "main": "index.js", 21 | "scripts": { 22 | "test": "ava '*.test.js' '**/*.test.js' '!integration_test/*'", 23 | "test:integration": "npm run db:init && ava --concurrency=1 integration_test/*.test.js", 24 | "test:cover": "nyc --reporter=text-lcov ava --concurrency=1 '*.test.js' '**/*.test.js' > coverage.lcov && codecov", 25 | "test:integration:auth": "npm run db:init && ava --concurrency=1 integration_test/auth.integration.test.js", 26 | "start": "node ./index.js", 27 | "dev": "nodemon ./index.js | pino-pretty --colorize --translateTime", 28 | "dev:memeo": "PLAYGROUND_QUERY_FILE='examples/memeolist.query.graphql' PLAYGROUND_VARIABLES_FILE='examples/memeolist.variables.json' nodemon ./index.js | pino-pretty --colorize --translateTime", 29 | "dev:keymemeo": "KEYCLOAK_CONFIG_FILE='keycloak/keycloak.json' PLAYGROUND_QUERY_FILE='examples/memeolist.query.graphql' PLAYGROUND_VARIABLES_FILE='examples/memeolist.variables.json' nodemon ./index.js | pino-pretty --colorize --translateTime", 30 | "lint": "eslint .", 31 | "format": "eslint . --fix", 32 | "docker:build": "./scripts/docker_build.sh", 33 | "docker:build:release": "./scripts/docker_build_release.sh", 34 | "docker:push": "./scripts/docker_push.sh", 35 | "docker:push:release": "./scripts/docker_push_release.sh", 36 | "compose:sync": "docker-compose -p sync up", 37 | "compose:sync:keycloak": "docker-compose -f keycloak/docker-compose.yml -p sync up", 38 | "release:validate": "./scripts/validateRelease.sh", 39 | "db:init": "FORCE_DROP=true node ./scripts/sync_models", 40 | "db:init:memeo:inmem": "FORCE_DROP=true node ./scripts/sync_models && sequelize db:seed --seed memeolist-example-inmem.js", 41 | "db:init:memeo:postgres": "FORCE_DROP=true node ./scripts/sync_models && sequelize db:seed --seed memeolist-example-postgres.js && docker exec sync_postgres_memeo_1 psql -U postgres -d memeolist_db -f /tmp/examples/memeolist.tables.sql", 42 | "db:shell": "docker exec -it sync_postgres_1 psql -U postgresql -d aerogear_data_sync_db", 43 | "db:shell:memeo": "docker exec -it sync_postgres_memeo_1 psql -U postgresql -d memeolist_db" 44 | }, 45 | "devDependencies": { 46 | "apollo-cache-inmemory": "^1.3.0-beta.6", 47 | "apollo-client": "^2.3.7", 48 | "apollo-link": "^1.2.2", 49 | "apollo-link-http": "^1.5.4", 50 | "apollo-link-ws": "^1.0.8", 51 | "apollo-utilities": "^1.0.17", 52 | "ava": "1.0.0-beta.6", 53 | "codecov": "^3.0.4", 54 | "eslint-config-standard": "^11.0.0", 55 | "eslint-plugin-import": "^2.13.0", 56 | "eslint-plugin-node": "^7.0.1", 57 | "eslint-plugin-promise": "^3.8.0", 58 | "eslint-plugin-standard": "^3.1.0", 59 | "graphql-tag": "^2.9.2", 60 | "node-fetch": "^2.2.0", 61 | "nodemon": "^1.17.5", 62 | "nyc": "^12.0.2", 63 | "pino-pretty": "^2.0.0", 64 | "pre-commit": "^1.2.2", 65 | "standard": "^11.0.1", 66 | "stoppable": "^1.0.6", 67 | "ws": "^6.0.0" 68 | }, 69 | "dependencies": { 70 | "@aerogear/data-sync-gql-core": "git+https://git@github.com/aerogear/data-sync-gql-core.git#master", 71 | "apollo-server-express": "^2.0.2", 72 | "apollo-server-module-graphiql": "1.3.4", 73 | "axios": "^0.18.0", 74 | "body-parser": "^1.18.3", 75 | "cors": "^2.8.4", 76 | "dotenv": "^6.0.0", 77 | "express": "^4.16.3", 78 | "express-pino-logger": "^4.0.0", 79 | "express-session": "^1.15.6", 80 | "graphql": "^0.13.2", 81 | "graphql-depth-limit": "^1.1.0", 82 | "graphql-postgres-subscriptions": "^1.0.2", 83 | "graphql-subscriptions": "^0.5.8", 84 | "graphql-tools": "^3.0.2", 85 | "graphql-validation-complexity": "^0.2.3", 86 | "handlebars": "^4.0.11", 87 | "joi": "^13.6.0", 88 | "json-parse-safe": "^1.0.5", 89 | "keycloak-connect": "^4.2.1", 90 | "lodash": "^4.17.10", 91 | "nedb": "^1.8.0", 92 | "pg": "^7.4.3", 93 | "pg-pubsub": "^0.4.0", 94 | "pino": "^5.0.0", 95 | "prom-client": "^11.1.1", 96 | "sequelize": "^4.38.0", 97 | "sequelize-cli": "^4.0.0", 98 | "subscriptions-transport-ws": "^0.9.11", 99 | "uuid": "^3.3.2" 100 | }, 101 | "pre-commit": [ 102 | "lint", 103 | "test" 104 | ] 105 | } 106 | -------------------------------------------------------------------------------- /scripts/docker_build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker build -t aerogear/data-sync-server:master . -------------------------------------------------------------------------------- /scripts/docker_build_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | RELEASE_TAG=$CIRCLE_TAG 4 | 5 | docker build -t aerogear/data-sync-server:$RELEASE_TAG . -------------------------------------------------------------------------------- /scripts/docker_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | APP_NAME=data-sync-server 4 | 5 | DOCKER_MASTER_TAG=aerogear/$APP_NAME:master 6 | 7 | docker login --username $DOCKERHUB_USERNAME --password $DOCKERHUB_PASSWORD 8 | docker push $DOCKER_MASTER_TAG 9 | -------------------------------------------------------------------------------- /scripts/docker_push_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | APP_NAME=data-sync-server 4 | 5 | RELEASE_TAG=$CIRCLE_TAG 6 | 7 | DOCKER_LATEST_TAG=aerogear/$APP_NAME:latest 8 | DOCKER_RELEASE_TAG=aerogear/$APP_NAME:$RELEASE_TAG 9 | 10 | docker login --username $DOCKERHUB_USERNAME --password $DOCKERHUB_PASSWORD 11 | docker tag $DOCKER_RELEASE_TAG $DOCKER_LATEST_TAG 12 | docker push $DOCKER_LATEST_TAG 13 | docker push $DOCKER_RELEASE_TAG 14 | -------------------------------------------------------------------------------- /scripts/sync_models.js: -------------------------------------------------------------------------------- 1 | const postgresConfig = require('../server/config').postgresConfig 2 | 3 | const forceDrop = process.env.FORCE_DROP === 'true' 4 | 5 | if (require.main === module) { 6 | require('@aerogear/data-sync-gql-core').models(postgresConfig).sequelize.sync({force: forceDrop}).then(() => { 7 | process.exit(0) 8 | }) 9 | } else { 10 | throw Error('This file should not be imported. Ever.') 11 | } 12 | -------------------------------------------------------------------------------- /scripts/validateRelease.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # explicit declaration that this script needs a $TAG variable passed in e.g TAG=1.2.3 ./script.sh 4 | TAG=$TAG 5 | TAG_SYNTAX='^[0-9]+\.[0-9]+\.[0-9]+(-.+)*$' 6 | 7 | # get version found in package.json. This is the source of truth 8 | PACKAGE_VERSION=$(cat package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[\",]//g' | tr -d '[[:space:]]') 9 | 10 | # validate tag has format x.y.z 11 | if [[ "$(echo $TAG | grep -E $TAG_SYNTAX)" == "" ]]; then 12 | echo "tag $TAG is invalid. Must be in the format x.y.z or x.y.z-SOME_TEXT" 13 | exit 1 14 | fi 15 | 16 | # validate that TAG == version found in package.json 17 | if [[ $TAG != $PACKAGE_VERSION ]]; then 18 | echo "tag $TAG is not the same as package version found in package.json $PACKAGE_VERSION" 19 | exit 1 20 | fi 21 | 22 | echo "TAG and PACKAGE_VERSION are valid" -------------------------------------------------------------------------------- /sequelize/config/database.js: -------------------------------------------------------------------------------- 1 | const { postgresConfig } = require('../../server/config') 2 | 3 | module.exports = { 4 | development: { 5 | database: postgresConfig.database, 6 | username: postgresConfig.username, 7 | password: postgresConfig.password, 8 | host: postgresConfig.options.host, 9 | dialect: 'postgres' 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /sequelize/models/index.js: -------------------------------------------------------------------------------- 1 | const core = require('@aerogear/data-sync-gql-core') 2 | const { postgresConfig } = require('../../server/config') 3 | 4 | const models = core.models(postgresConfig) 5 | 6 | module.exports = models 7 | -------------------------------------------------------------------------------- /sequelize/seeders/memeolist-example-inmem.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { schema, subscriptions } = require('./memeolist-example-shared') 4 | 5 | const time = new Date() 6 | 7 | const datasources = [ 8 | { 9 | id: 1, 10 | name: 'nedb_memeolist', 11 | type: 'InMemory', 12 | config: '{"options":{"timestampData":true}}', 13 | createdAt: time, 14 | updatedAt: time 15 | } 16 | ] 17 | 18 | const resolvers = [ 19 | { 20 | type: 'Meme', 21 | field: 'owner', 22 | DataSourceId: 1, 23 | GraphQLSchemaId: 1, 24 | requestMapping: `{"operation": "find", "query": 25 | {"_type":"profile", "id": "{{context.parent.owner}}"}}`, 26 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 27 | createdAt: time, 28 | updatedAt: time 29 | }, 30 | { 31 | type: 'Profile', 32 | field: 'memes', 33 | DataSourceId: 1, 34 | GraphQLSchemaId: 1, 35 | requestMapping: `{"operation": "find", "query": 36 | {"_type":"meme", "owner": "{{context.parent.id}}"}}`, 37 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 38 | createdAt: time, 39 | updatedAt: time 40 | }, 41 | { 42 | type: 'Comment', 43 | field: 'owner', 44 | DataSourceId: 1, 45 | GraphQLSchemaId: 1, 46 | requestMapping: `{"operation": "find", "query": 47 | {"_type":"profile", "id": "{{context.parent.owner}}"}}`, 48 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 49 | createdAt: time, 50 | updatedAt: time 51 | }, 52 | { 53 | type: 'Query', 54 | field: 'allMemes', 55 | DataSourceId: 1, 56 | GraphQLSchemaId: 1, 57 | preHook: '', 58 | postHook: '', 59 | requestMapping: '{"operation": "find", "query": {"_type":"meme"}}', 60 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 61 | createdAt: time, 62 | updatedAt: time 63 | }, 64 | { 65 | type: 'Query', 66 | field: 'profile', 67 | DataSourceId: 1, 68 | GraphQLSchemaId: 1, 69 | requestMapping: '{"operation": "find", "query": {"_type":"profile", "email": "{{context.arguments.email}}" }}', 70 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 71 | createdAt: time, 72 | updatedAt: time 73 | }, 74 | { 75 | type: 'Query', 76 | field: 'comments', 77 | DataSourceId: 1, 78 | GraphQLSchemaId: 1, 79 | requestMapping: '{"operation": "find", "query": {"_type":"comment", "memeid": "{{context.arguments.memeid}}" }}', 80 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 81 | createdAt: time, 82 | updatedAt: time 83 | }, 84 | { 85 | type: 'Mutation', 86 | field: 'createMeme', 87 | DataSourceId: 1, 88 | GraphQLSchemaId: 1, 89 | preHook: '', 90 | postHook: '', 91 | requestMapping: `{ 92 | "operation": "insert", 93 | "doc": { 94 | "_type":"meme", 95 | "photourl": "{{context.arguments.photourl}}", 96 | "owner": "{{context.arguments.owner}}", 97 | "likes": 0 98 | } 99 | }`, 100 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 101 | publish: JSON.stringify({ 102 | topic: 'memeCreated', 103 | payload: `{ 104 | "memeAdded": {{ toJSON context.result }} 105 | }` 106 | }), 107 | createdAt: time, 108 | updatedAt: time 109 | }, 110 | { 111 | type: 'Mutation', 112 | field: 'createProfile', 113 | DataSourceId: 1, 114 | GraphQLSchemaId: 1, 115 | requestMapping: `{ 116 | "operation": "insert", 117 | "doc": { 118 | "_type":"profile", 119 | "email": "{{context.arguments.email}}", 120 | "displayname": "{{context.arguments.displayname}}", 121 | "pictureurl": "{{context.arguments.pictureurl}}" 122 | } 123 | }`, 124 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 125 | createdAt: time, 126 | updatedAt: time 127 | }, 128 | { 129 | type: 'Mutation', 130 | field: 'likeMeme', 131 | DataSourceId: 1, 132 | GraphQLSchemaId: 1, 133 | requestMapping: `{ 134 | "operation": "update", 135 | "query": {"_id": "{{context.arguments.id}}", "_type":"meme"}, 136 | "update": { 137 | "$inc": { "likes" : 1 } 138 | } 139 | }`, 140 | responseMapping: 'true', 141 | createdAt: time, 142 | updatedAt: time 143 | }, { 144 | type: 'Mutation', 145 | field: 'postComment', 146 | DataSourceId: 1, 147 | GraphQLSchemaId: 1, 148 | requestMapping: `{ 149 | "operation": "insert", 150 | "doc": { 151 | "_type":"comment", 152 | "comment": "{{context.arguments.comment}}", 153 | "owner": "{{context.arguments.owner}}", 154 | "memeid": "{{context.arguments.memeid}}" 155 | } 156 | }`, 157 | responseMapping: '{{ toJSON (convertNeDBIds context.result) }}', 158 | createdAt: time, 159 | updatedAt: time 160 | } 161 | ] 162 | 163 | module.exports = { 164 | up: async (queryInterface, Sequelize) => { 165 | await queryInterface.bulkInsert('DataSources', datasources, {}) 166 | await queryInterface.bulkInsert('GraphQLSchemas', [schema], {}) 167 | await queryInterface.bulkInsert('Subscriptions', subscriptions, {}) 168 | return queryInterface.bulkInsert('Resolvers', resolvers, {}) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /sequelize/seeders/memeolist-example-postgres.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { schema, subscriptions } = require('./memeolist-example-shared') 4 | 5 | const time = new Date() 6 | 7 | const datasources = [ 8 | { 9 | id: 1, 10 | name: 'memeolist_postgres', 11 | type: 'Postgres', 12 | config: `{"options":{ 13 | "user": "postgresql", 14 | "password": "postgres", 15 | "database": "memeolist_db", 16 | "host": "127.0.0.1", 17 | "port": "15432", 18 | "dialect": "postgres" 19 | }}`, 20 | createdAt: time, 21 | updatedAt: time 22 | } 23 | ] 24 | 25 | const resolvers = [ 26 | { 27 | type: 'Meme', 28 | field: 'owner', 29 | DataSourceId: 1, 30 | GraphQLSchemaId: 1, 31 | requestMapping: `SELECT * FROM profile WHERE id='{{context.parent.owner}}' ORDER BY id DESC`, 32 | responseMapping: '{{ toJSON context.result.[0] }}', 33 | createdAt: time, 34 | updatedAt: time 35 | }, 36 | { 37 | type: 'Profile', 38 | field: 'memes', 39 | DataSourceId: 1, 40 | GraphQLSchemaId: 1, 41 | requestMapping: `SELECT * FROM meme WHERE owner='{{context.parent.id}}' ORDER BY id DESC`, 42 | responseMapping: '{{ toJSON context.result }}', 43 | createdAt: time, 44 | updatedAt: time 45 | }, 46 | { 47 | type: 'Comment', 48 | field: 'owner', 49 | DataSourceId: 1, 50 | GraphQLSchemaId: 1, 51 | requestMapping: `SELECT * FROM profile WHERE id='{{context.parent.owner}}' ORDER BY id DESC`, 52 | responseMapping: '{{ toJSON context.result.[0] }}', 53 | createdAt: time, 54 | updatedAt: time 55 | }, 56 | { 57 | type: 'Query', 58 | field: 'allMemes', 59 | DataSourceId: 1, 60 | GraphQLSchemaId: 1, 61 | requestMapping: 'SELECT * FROM meme ORDER BY id DESC', 62 | responseMapping: '{{ toJSON context.result }}', 63 | createdAt: time, 64 | updatedAt: time 65 | }, 66 | { 67 | type: 'Query', 68 | field: 'profile', 69 | DataSourceId: 1, 70 | GraphQLSchemaId: 1, 71 | requestMapping: `SELECT * FROM profile WHERE email='{{context.arguments.email}}'`, 72 | responseMapping: '{{ toJSON context.result }}', 73 | createdAt: time, 74 | updatedAt: time 75 | }, 76 | { 77 | type: 'Query', 78 | field: 'comments', 79 | DataSourceId: 1, 80 | GraphQLSchemaId: 1, 81 | requestMapping: `SELECT * FROM comment WHERE memeid='{{context.arguments.memeid}}' ORDER BY id DESC`, 82 | responseMapping: '{{ toJSON context.result }}', 83 | createdAt: time, 84 | updatedAt: time 85 | }, 86 | { 87 | type: 'Mutation', 88 | field: 'createMeme', 89 | DataSourceId: 1, 90 | GraphQLSchemaId: 1, 91 | requestMapping: `INSERT INTO meme ("owner","photourl", "likes") VALUES ('{{context.arguments.owner}}','{{context.arguments.photourl}}', 0) RETURNING *;`, 92 | responseMapping: '{{ toJSON context.result.[0] }}', 93 | publish: JSON.stringify({ 94 | topic: 'memeCreated', 95 | payload: `{ 96 | "memeAdded": {{ toJSON context.result }} 97 | }` 98 | }), 99 | createdAt: time, 100 | updatedAt: time 101 | }, 102 | { 103 | type: 'Mutation', 104 | field: 'createProfile', 105 | DataSourceId: 1, 106 | GraphQLSchemaId: 1, 107 | requestMapping: `INSERT INTO profile ("email", "displayname", "pictureurl") VALUES ('{{context.arguments.email}}','{{context.arguments.displayname}}','{{context.arguments.pictureurl}}') RETURNING *;`, 108 | responseMapping: '{{ toJSON context.result.[0] }}', 109 | createdAt: time, 110 | updatedAt: time 111 | }, 112 | { 113 | type: 'Mutation', 114 | field: 'likeMeme', 115 | DataSourceId: 1, 116 | GraphQLSchemaId: 1, 117 | requestMapping: `UPDATE meme SET likes=likes+1 WHERE id={{context.arguments.id}} RETURNING *;`, 118 | responseMapping: 'true', 119 | createdAt: time, 120 | updatedAt: time 121 | }, { 122 | type: 'Mutation', 123 | field: 'postComment', 124 | DataSourceId: 1, 125 | GraphQLSchemaId: 1, 126 | requestMapping: `INSERT INTO comment ("comment", "owner", "memeid") VALUES ('{{context.arguments.comment}}','{{context.arguments.owner}}','{{context.arguments.memeid}}') RETURNING *;`, 127 | responseMapping: '{{ toJSON context.result.[0] }}', 128 | createdAt: time, 129 | updatedAt: time 130 | } 131 | ] 132 | 133 | module.exports = { 134 | up: async (queryInterface, Sequelize) => { 135 | await queryInterface.bulkInsert('DataSources', datasources, {}) 136 | await queryInterface.bulkInsert('GraphQLSchemas', [schema], {}) 137 | await queryInterface.bulkInsert('Subscriptions', subscriptions, {}) 138 | return queryInterface.bulkInsert('Resolvers', resolvers, {}) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /sequelize/seeders/memeolist-example-shared.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const time = new Date() 4 | 5 | const subscriptions = [ 6 | { 7 | type: 'Subscription', 8 | field: 'memeAdded', 9 | GraphQLSchemaId: 1, 10 | topic: 'memeCreated', 11 | filter: JSON.stringify({ 12 | match: ['$payload.memeAdded.photourl', 'https://.*'] 13 | }), 14 | createdAt: time, 15 | updatedAt: time 16 | } 17 | ] 18 | 19 | const memeoListSchema = { 20 | id: 1, 21 | name: 'default', 22 | schema: ` 23 | type Profile { 24 | id: ID! 25 | email: String! 26 | displayname: String 27 | pictureurl: String 28 | memes: [Meme!]! 29 | } 30 | 31 | type Meme { 32 | id: ID! 33 | photourl: String! 34 | likes: Int! 35 | owner: Profile! 36 | } 37 | 38 | type Comment { 39 | id: ID! 40 | comment: String! 41 | owner: Profile! 42 | } 43 | 44 | type Query { 45 | profile(email: String!): [Profile]! 46 | allMemes:[Meme!]! 47 | comments(memeid: ID!): [Comment]! 48 | } 49 | 50 | type Mutation { 51 | createProfile(email: String!, displayname: String!, pictureurl: String!):Profile! @hasRole(role: "realm:admin") 52 | createMeme(owner: ID!, photourl: String!):Meme! 53 | likeMeme(id: ID!): Boolean @hasRole(role: "realm:voter") 54 | postComment(memeid: ID!, comment: String!, owner: ID!): Comment! 55 | } 56 | 57 | type Subscription { 58 | memeAdded(photourl: String):Meme! 59 | } 60 | `, 61 | createdAt: time, 62 | updatedAt: time 63 | } 64 | 65 | module.exports = { 66 | schema: memeoListSchema, 67 | subscriptions 68 | } 69 | -------------------------------------------------------------------------------- /server/apolloServer.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server-express') 2 | const queryDepthLimit = require('graphql-depth-limit') 3 | const { GraphQLError } = require('graphql') 4 | const { createComplexityLimitRule } = require('graphql-validation-complexity') 5 | const { log, auditLogEnabled, auditLog } = require('./lib/util/logger') 6 | 7 | const NOOP = function () { 8 | } 9 | 10 | function newApolloServer (app, schema, httpServer, tracing, playgroundConfig, graphqlEndpoint, securityService, serverSecurity) { 11 | let AuthContextProvider = null 12 | 13 | if (securityService) { 14 | AuthContextProvider = securityService.getAuthContextProvider() 15 | } 16 | 17 | let apolloServer = new ApolloServer({ 18 | schema, 19 | validationRules: [ 20 | queryDepthLimit(serverSecurity.queryDepthLimit), 21 | createComplexityLimitRule(serverSecurity.complexityLimit, { 22 | createError (cost, documentNode) { 23 | const error = new GraphQLError(`query with ${cost} exceeds complexity limit`, [documentNode]) 24 | log.debug(error) 25 | return error 26 | } 27 | }) 28 | ], 29 | context: async ({ req }) => { 30 | const context = { 31 | request: req 32 | } 33 | if (AuthContextProvider) { 34 | context.auth = new AuthContextProvider(req) 35 | } 36 | if (auditLogEnabled) { 37 | // clientInfo is available in the request, decoded already 38 | // just attach it to context 39 | context.clientInfo = req ? req.clientInfo : undefined 40 | context.auditLog = auditLog 41 | } else { 42 | context.clientInfo = undefined 43 | context.auditLog = NOOP 44 | } 45 | return context 46 | }, 47 | tracing, 48 | playground: { 49 | settings: { 50 | 'editor.theme': 'light', 51 | 'editor.cursorShape': 'block' 52 | }, 53 | tabs: [ 54 | { 55 | endpoint: playgroundConfig.endpoint, 56 | query: playgroundConfig.query, 57 | variables: JSON.stringify(playgroundConfig.variables) 58 | } 59 | ] 60 | } 61 | }) 62 | apolloServer.applyMiddleware({ app, disableHealthCheck: true, path: graphqlEndpoint }) 63 | apolloServer.installSubscriptionHandlers(httpServer) 64 | 65 | return apolloServer 66 | } 67 | 68 | module.exports = newApolloServer 69 | -------------------------------------------------------------------------------- /server/config/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { hostname } = require('os') 3 | 4 | const { log } = require('../lib/util/logger') 5 | 6 | require('dotenv').config() 7 | 8 | let playgroundQueryFileContent = '' 9 | let playgroundVariableFileContent = '' 10 | 11 | if (process.env.PLAYGROUND_QUERY_FILE) { 12 | try { 13 | playgroundQueryFileContent = fs.readFileSync(process.env.PLAYGROUND_QUERY_FILE, 'utf-8') 14 | } catch (ex) { 15 | log.error(`Unable to read PLAYGROUND_QUERY_FILE ${process.env.PLAYGROUND_QUERY_FILE} . Skipping it.`) 16 | log.error(ex) 17 | } 18 | } 19 | 20 | if (process.env.PLAYGROUND_VARIABLES_FILE) { 21 | try { 22 | playgroundVariableFileContent = fs.readFileSync(process.env.PLAYGROUND_VARIABLES_FILE, 'utf-8') 23 | playgroundVariableFileContent = JSON.parse(playgroundVariableFileContent) 24 | } catch (ex) { 25 | log.error(`Unable to read PLAYGROUND_VARIABLES_FILE ${process.env.PLAYGROUND_VARIABLES_FILE} . Skipping it.`) 26 | log.error(ex) 27 | } 28 | } 29 | 30 | const graphqlEndpoint = '/graphql' 31 | const port = process.env.HTTP_PORT || '8000' 32 | 33 | const config = { 34 | server: { 35 | port 36 | }, 37 | graphQLConfig: { 38 | graphqlEndpoint, 39 | tracing: true 40 | }, 41 | playgroundConfig: { 42 | endpoint: graphqlEndpoint, // if you want GraphiQL enabled 43 | query: playgroundQueryFileContent, 44 | variables: playgroundVariableFileContent, 45 | subscriptionEndpoint: process.env.PLAYGROUND_SUBS_ENDPOINT || `ws://${hostname()}:${port}/subscriptions` 46 | }, 47 | postgresConfig: { 48 | database: process.env.POSTGRES_DATABASE || 'aerogear_data_sync_db', 49 | username: process.env.POSTGRES_USERNAME || 'postgresql', 50 | password: process.env.POSTGRES_PASSWORD || 'postgres', 51 | options: { 52 | host: process.env.POSTGRES_HOST || '127.0.0.1', 53 | port: process.env.POSTGRES_PORT || '5432', 54 | dialect: 'postgres', 55 | operatorsAliases: false, 56 | logging: false 57 | } 58 | }, 59 | pubsubConfig: {}, 60 | securityServiceConfig: { 61 | type: null, // e.g. type 'keycloak' or 'passport' 62 | config: null // implementation specific config 63 | }, 64 | schemaListenerConfig: undefined, 65 | serverSecurity: { 66 | queryDepthLimit: process.env.QUERY_DEPTH_LIMIT || 20, 67 | complexityLimit: process.env.COMPLEXITY_LIMIT || 10000 68 | } 69 | } 70 | 71 | if (process.env.SCHEMA_LISTENER_CONFIG) { 72 | let schemaListenerConfigStr 73 | try { 74 | schemaListenerConfigStr = Buffer.from(process.env.SCHEMA_LISTENER_CONFIG, 'base64').toString() 75 | } catch (ex) { 76 | log.error(`Cannot base64 decode SCHEMA_LISTENER_CONFIG environment variable: ${process.env.SCHEMA_LISTENER_CONFIG}`) 77 | process.exit(1) 78 | } 79 | 80 | try { 81 | config.schemaListenerConfig = JSON.parse(schemaListenerConfigStr) 82 | } catch (ex) { 83 | log.error(`Base64 decoded SCHEMA_LISTENER_CONFIG environment variable is not valid json: ${schemaListenerConfigStr}`) 84 | process.exit(1) 85 | } 86 | } else { 87 | log.info(`Using default schemaListener since SCHEMA_LISTENER_CONFIG environment variable is not defined`) 88 | 89 | config.pubsubConfig = { 90 | type: 'Postgres', 91 | config: { 92 | database: config.postgresConfig.database, 93 | user: config.postgresConfig.username, 94 | password: config.postgresConfig.password, 95 | host: config.postgresConfig.options.host, 96 | port: config.postgresConfig.options.port 97 | } 98 | } 99 | 100 | config.schemaListenerConfig = { 101 | type: 'postgres', 102 | config: { 103 | channel: 'aerogear-data-sync-config', 104 | database: config.postgresConfig.database, 105 | username: config.postgresConfig.username, 106 | password: config.postgresConfig.password, 107 | host: config.postgresConfig.options.host, 108 | port: config.postgresConfig.options.port 109 | } 110 | } 111 | } 112 | 113 | if (process.env.KEYCLOAK_CONFIG_FILE) { 114 | try { 115 | const keycloakConfig = fs.readFileSync(process.env.KEYCLOAK_CONFIG_FILE, 'utf-8') 116 | 117 | config.securityServiceConfig.type = 'keycloak' 118 | config.securityServiceConfig.config = JSON.parse(keycloakConfig) 119 | } catch (ex) { 120 | log.error(`Unable to read keycloakConfig in ${process.env.KEYCLOAK_CONFIG_FILE} . Skipping it.`) 121 | log.error(ex) 122 | } 123 | } 124 | 125 | module.exports = config 126 | -------------------------------------------------------------------------------- /server/expressApp.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const cors = require('cors') 3 | const { runHealthChecks } = require('./health') 4 | const { log, getClientInfoFromHeaders, auditLogEnabled } = require('./lib/util/logger') 5 | const { ApolloError } = require('apollo-server-express') 6 | 7 | const VALIDATION_ERROR = 'VALIDATION_ERROR' 8 | 9 | function newExpressApp (options, middlewares, securityService) { 10 | let app = express() 11 | const { metrics, responseLoggingMetric, logging } = middlewares 12 | const { graphqlEndpoint, models } = options 13 | 14 | app.use(responseLoggingMetric) 15 | app.use('*', cors()) 16 | app.use(logging) 17 | 18 | app.get('/healthz', async (req, res) => { 19 | const result = await runHealthChecks(models) 20 | if (!result.ok) { 21 | res.status(503) 22 | } 23 | res.json(result) 24 | }) 25 | 26 | app.get('/metrics', metrics) 27 | 28 | if (securityService) { 29 | securityService.applyAuthMiddleware(app, graphqlEndpoint) 30 | } 31 | 32 | if (auditLogEnabled) { 33 | app.use(function (req, res, next) { 34 | try { 35 | req.clientInfo = getClientInfoFromHeaders(req) 36 | } catch (e) { 37 | log.error('Error getting client info.') 38 | log.error(e) 39 | const message = 'Error decoding/parsing malformed client info. Message: ' + e.message 40 | 41 | // for some reason, the first parameter (message) is not sent to clients. 42 | // adding the same message as "msg" in the error message separately. 43 | return res.status(400).send(new ApolloError(message, VALIDATION_ERROR, { msg: message })) 44 | } 45 | 46 | next() 47 | }) 48 | } 49 | 50 | return app 51 | } 52 | 53 | module.exports = newExpressApp 54 | -------------------------------------------------------------------------------- /server/health/index.js: -------------------------------------------------------------------------------- 1 | const { log } = require('../lib/util/logger') 2 | 3 | exports.runHealthChecks = async function (db) { 4 | // Checks database connectivity by auth attempt 5 | function databaseConnectivityCheck () { 6 | return db.sequelize.authenticate() 7 | } 8 | 9 | function handleResolve ({label, promise}) { 10 | promise = promise.then(() => { 11 | return {[label]: true} 12 | }) 13 | return {label, promise} 14 | } 15 | 16 | function handleReject ({label, promise}) { 17 | return promise.catch(err => { 18 | log.error(err) 19 | return {[label]: false} 20 | }) 21 | } 22 | 23 | /** 24 | * Summarize the results of all checks in to a report of the form of: 25 | * { 26 | * ok: , 27 | * checks: [ 28 | * {check1: }, 29 | * {check2: } 30 | * ] 31 | * } 32 | * @param results 33 | * @returns {{ok: boolean, checks: *}} 34 | */ 35 | function summarize (results) { 36 | const overallStatus = { 37 | ok: false, 38 | checks: results 39 | } 40 | 41 | overallStatus.ok = results.reduce((acc, result) => { 42 | return acc && Object.values(result).reduce((acc, val) => acc && val, true) 43 | }, true) 44 | 45 | return overallStatus 46 | } 47 | 48 | /** 49 | * Runs all health checks defined in `checks` and returns an array 50 | * with the results in the form of: 51 | * [ 52 | * {check1: }, 53 | * {check2: } 54 | * ] 55 | */ 56 | function run () { 57 | // Add additional health checks to this array 58 | const checks = [{ 59 | label: 'Database Connectivity', promise: databaseConnectivityCheck() 60 | }] 61 | .map(handleResolve) 62 | .map(handleReject) 63 | 64 | return Promise.all(checks) 65 | } 66 | 67 | const results = await run() 68 | return summarize(results) 69 | } 70 | -------------------------------------------------------------------------------- /server/lib/pubsubNotifiers/notifiers/inMemory.js: -------------------------------------------------------------------------------- 1 | const PubSub = require('graphql-subscriptions').PubSub 2 | const { log } = require('../../util/logger') 3 | 4 | function InMemoryPubSub (config) { 5 | const client = new PubSub() 6 | 7 | this.publish = function publish ({ topic, compiledPayload }, context) { 8 | let payload = compiledPayload(context) 9 | // The InMemory pubsub implementation wants an object 10 | // Whereas the postgres one would expect a string 11 | try { 12 | payload = JSON.parse(payload) 13 | log.info('publishing to topic', { topic, payload }) 14 | client.publish(topic, payload) 15 | } catch (error) { 16 | log.error('failed to publish to topic: invalid payload', error, payload) 17 | } 18 | } 19 | 20 | this.getAsyncIterator = function getAsyncIterator (topic) { 21 | return client.asyncIterator(topic) 22 | } 23 | } 24 | 25 | module.exports = InMemoryPubSub 26 | -------------------------------------------------------------------------------- /server/lib/pubsubNotifiers/notifiers/index.js: -------------------------------------------------------------------------------- 1 | 2 | // pub sub implementations for subscriptions 3 | module.exports = { 4 | InMemory: require('./inMemory'), // Not for prod, should only be used for testing 5 | Postgres: require('./postgres') 6 | } 7 | -------------------------------------------------------------------------------- /server/lib/pubsubNotifiers/notifiers/postgres.js: -------------------------------------------------------------------------------- 1 | const { log } = require('../../util/logger') 2 | const { PostgresPubSub } = require('graphql-postgres-subscriptions') 3 | 4 | function NewPostgresPubSub (config) { 5 | const client = new PostgresPubSub(config) 6 | 7 | this.publish = function publish ({ topic, compiledPayload }, context) { 8 | let payload = compiledPayload(context) 9 | log.info('publishing to topic', { topic, payload }) 10 | client.publish(topic, payload) 11 | } 12 | 13 | this.getAsyncIterator = function getAsyncIterator (topic) { 14 | return client.asyncIterator(topic) 15 | } 16 | } 17 | 18 | module.exports = NewPostgresPubSub 19 | -------------------------------------------------------------------------------- /server/lib/pubsubNotifiers/pubsubNotifier.js: -------------------------------------------------------------------------------- 1 | const notifiers = require('./notifiers') 2 | 3 | module.exports = function NewPubSub (pubsubConfig) { 4 | const PubSubClass = notifiers[pubsubConfig.type] 5 | 6 | if (!PubSubClass) { 7 | throw new Error(`Unhandled pubsub type: ${pubsubConfig.type}`) 8 | } 9 | 10 | if (typeof PubSubClass !== 'function') { 11 | throw new Error(`PubSub implementation for ${pubsubConfig.type} is missing a constructor`) 12 | } 13 | 14 | const pubsub = new PubSubClass(pubsubConfig.config) 15 | 16 | if (!pubsub.publish && typeof pubsub.publish !== 'function') { 17 | throw new Error(`Pubsub implementation for ${pubsubConfig.type} is missing "publish" function`) 18 | } 19 | 20 | if (!pubsub.getAsyncIterator && typeof pubsub.getAsyncIterator !== 'function') { 21 | throw new Error(`Pubsub implementation for ${pubsubConfig.type} is missing "getAsyncIterator" function`) 22 | } 23 | 24 | return pubsub 25 | } 26 | -------------------------------------------------------------------------------- /server/lib/pubsubNotifiers/pubsubNotifier.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | 3 | const pubsubNotifier = require('./pubsubNotifier') 4 | 5 | test('It should get InMemory Pubsub Notifier', t => { 6 | const config = { type: 'InMemory' } 7 | 8 | const pubsub = pubsubNotifier(config) 9 | t.truthy(pubsub) 10 | }) 11 | 12 | test('It should get Postgres Pubsub Notifier', t => { 13 | const config = { type: 'Postgres' } 14 | 15 | const pubsub = pubsubNotifier(config) 16 | t.truthy(pubsub) 17 | }) 18 | 19 | test('It should throw an error when an unknown type is supplied', t => { 20 | const config = { type: 'unknown' } 21 | 22 | t.throws(() => { 23 | pubsubNotifier(config) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /server/lib/schemaListeners/listeners/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | postgres: require('./postgres') 3 | } 4 | -------------------------------------------------------------------------------- /server/lib/schemaListeners/listeners/postgres.js: -------------------------------------------------------------------------------- 1 | const PGPubsub = require('pg-pubsub') 2 | const { log } = require('../../util/logger') 3 | 4 | function PostgresListener (config) { 5 | let pubsubInstance 6 | 7 | pubsubInstance = new PGPubsub({ 8 | user: config.username, 9 | host: config.host, 10 | database: config.database, 11 | password: config.password, 12 | port: config.port 13 | }, { log: log.info.bind(log) }) 14 | 15 | this.start = function (onReceive) { 16 | pubsubInstance.addChannel(config.channel, async function () { 17 | log.info('Received notification from listened Postgres channel') 18 | onReceive() 19 | }) 20 | } 21 | 22 | this.stop = function () { 23 | pubsubInstance.close() 24 | } 25 | 26 | return this 27 | } 28 | 29 | module.exports = PostgresListener 30 | -------------------------------------------------------------------------------- /server/lib/schemaListeners/schemaListenerCreator.js: -------------------------------------------------------------------------------- 1 | const listeners = require('./listeners') 2 | 3 | module.exports = function (schemaListenerConfig) { 4 | const ListenerClass = listeners[schemaListenerConfig.type] 5 | 6 | if (!ListenerClass) { 7 | throw new Error(`Unhandled schema listener type: ${schemaListenerConfig.type}`) 8 | } 9 | 10 | if (typeof ListenerClass !== 'function') { 11 | throw new Error(`Schema listener for ${schemaListenerConfig.type} is missing a constructor`) 12 | } 13 | 14 | const listener = new ListenerClass(schemaListenerConfig.config) 15 | 16 | if (!listener.start && typeof listener.start !== 'function') { 17 | throw new Error(`Schema listener for ${schemaListenerConfig.type} is missing "start" function`) 18 | } 19 | 20 | if (!listener.stop && typeof listener.stop !== 'function') { 21 | throw new Error(`Schema listener for ${schemaListenerConfig.type} is missing "stop" function`) 22 | } 23 | 24 | return listener 25 | } 26 | -------------------------------------------------------------------------------- /server/lib/schemaListeners/schemaListenerCreator.test.js: -------------------------------------------------------------------------------- 1 | const {test} = require('ava') 2 | 3 | const schemaListenerCreator = require('./schemaListenerCreator') 4 | 5 | test('should get schemaListener successfully - no errors thrown', t => { 6 | const schemaListenerConfig = { 7 | type: 'postgres', 8 | config: { 9 | channel: 'aerogear-data-sync-config', 10 | database: 'aerogear_data_sync_db', 11 | user: 'postgres', 12 | password: 'postgres', 13 | host: '127.0.0.1', 14 | port: 5432 15 | } 16 | } 17 | 18 | const listener = schemaListenerCreator(schemaListenerConfig) 19 | t.truthy(listener) 20 | }) 21 | 22 | test('should throw an error when the schema listener type is unknown', t => { 23 | const schemaListenerConfig = { 24 | type: 'unknown' 25 | } 26 | t.throws(() => { 27 | schemaListenerCreator(schemaListenerConfig) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /server/lib/util/internalServerError.js: -------------------------------------------------------------------------------- 1 | const { ApolloError } = require('apollo-server-express') 2 | const uuid = require('uuid') 3 | 4 | const INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR' 5 | 6 | function newInternalServerError (context) { 7 | let errorId = (context && context.request) ? context.request.id : uuid.v4() 8 | const genericErrorMsg = `An internal server error occurred, please contact the server administrator and provide the following id: ${errorId}` 9 | return new ApolloError(genericErrorMsg, INTERNAL_SERVER_ERROR, { id: errorId }) 10 | } 11 | 12 | module.exports = newInternalServerError 13 | -------------------------------------------------------------------------------- /server/lib/util/internalServerError.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const newInternalServerError = require('./internalServerError') 3 | 4 | const INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR' 5 | const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i 6 | 7 | test('new internal server error returns a generic error with context.request.id', (t) => { 8 | const context = { 9 | request: { 10 | id: '123' 11 | } 12 | } 13 | const error = newInternalServerError(context) 14 | 15 | t.deepEqual(error.id, context.request.id) 16 | t.deepEqual(error.extensions.code, INTERNAL_SERVER_ERROR) 17 | t.deepEqual(error.message, `An internal server error occurred, please contact the server administrator and provide the following id: ${context.request.id}`) 18 | }) 19 | 20 | test('new internal server error returns a generic error with a uuid when no context is available', (t) => { 21 | const error = newInternalServerError() 22 | 23 | t.truthy(error.id) 24 | t.regex(error.id, uuidRegex) 25 | t.deepEqual(error.extensions.code, INTERNAL_SERVER_ERROR) 26 | }) 27 | -------------------------------------------------------------------------------- /server/lib/util/logger.js: -------------------------------------------------------------------------------- 1 | const log = require('pino')() 2 | const auditLogger = log.child({tag: 'AUDIT'}) 3 | const { buildPath } = require('@aerogear/data-sync-gql-core').util.graphqlPathUtil 4 | 5 | const auditLogEnabled = process.env.AUDIT_LOGGING !== 'false' && process.env.AUDIT_LOGGING !== false 6 | 7 | function getClientInfoFromHeaders (request) { 8 | if (request && request.headers && request.headers['data-sync-client-info']) { 9 | const encoded = request.headers['data-sync-client-info'] 10 | let buf 11 | try { 12 | buf = Buffer.from(encoded, 'base64') 13 | } catch (e) { 14 | const msg = 'Unable decode base64 data-sync-client-info header provided by the client. Message: ' + e.message 15 | log.error(msg) 16 | throw new Error(msg) 17 | } 18 | 19 | const decoded = buf.toString('utf8') 20 | try { 21 | return JSON.parse(decoded) 22 | } catch (e) { 23 | const msg = 'Unable to parse data-sync-client-info header provided by the client. Message: ' + e.message 24 | log.error(msg) 25 | throw new Error(msg) 26 | } 27 | } 28 | return undefined 29 | } 30 | 31 | function auditLog (success, context, info, parent, args, msg) { 32 | if (auditLogEnabled) { 33 | auditLogger.info({ 34 | audit: { 35 | msg: msg || '', 36 | requestId: context ? (context.request ? context.request.id : undefined) : undefined, 37 | operationType: info.operation.operation, 38 | fieldName: info.fieldname, 39 | parentTypeName: info.parentType.name, 40 | path: buildPath(info.path), 41 | success: success, 42 | parent: parent, 43 | arguments: args, 44 | dataSourceType: info.dataSourceType || '', 45 | clientInfo: context ? context.clientInfo : undefined, 46 | authenticated: !!(context && context.auth && context.auth.isAuthenticated()), 47 | userInfo: (context && context.auth) ? context.auth.getTokenContent() : undefined 48 | } 49 | }) 50 | } 51 | } 52 | 53 | module.exports = {log, getClientInfoFromHeaders, auditLogEnabled, auditLog} 54 | -------------------------------------------------------------------------------- /server/lib/util/logger.test.js: -------------------------------------------------------------------------------- 1 | const {test} = require('ava') 2 | 3 | const {getClientInfoFromHeaders} = require('./logger') 4 | 5 | test('getClientInfoFromHeaders should not throw error if the header value is missing', t => { 6 | t.is(getClientInfoFromHeaders(undefined), undefined) 7 | 8 | t.is(getClientInfoFromHeaders(null), undefined) 9 | t.is(getClientInfoFromHeaders(1), undefined) 10 | t.is(getClientInfoFromHeaders('foo'), undefined) 11 | t.is(getClientInfoFromHeaders({}), undefined) 12 | t.is(getClientInfoFromHeaders({foo: 1}), undefined) 13 | 14 | t.is(getClientInfoFromHeaders({headers: null}), undefined) 15 | t.is(getClientInfoFromHeaders({headers: 1}), undefined) 16 | t.is(getClientInfoFromHeaders({headers: 'foo'}), undefined) 17 | t.is(getClientInfoFromHeaders({headers: {}}), undefined) 18 | t.is(getClientInfoFromHeaders({headers: {foo: 1}}), undefined) 19 | 20 | // structure ok, missing data 21 | t.is(getClientInfoFromHeaders({ 22 | headers: { 23 | 'data-sync-client-info': null 24 | } 25 | }), undefined) 26 | }) 27 | 28 | test('getClientInfoFromHeaders should throw error if the header value is malformed', t => { 29 | // structure ok, invalid base64 30 | t.throws( 31 | () => { getClientInfoFromHeaders({headers: {'data-sync-client-info': 1}}) }, 32 | Error 33 | ) 34 | 35 | // structure ok, invalid base64 36 | t.throws( 37 | () => { getClientInfoFromHeaders({headers: {'data-sync-client-info': 'foo'}}) }, 38 | Error 39 | ) 40 | 41 | // structure ok, base64 ok, value not JSON 42 | t.throws( 43 | () => { getClientInfoFromHeaders({headers: {'data-sync-client-info': Buffer.from('foo', 'utf8').toString('base64')}}) }, 44 | Error 45 | ) 46 | }) 47 | 48 | test('getClientInfoFromHeaders return client info successfully', t => { 49 | t.deepEqual(getClientInfoFromHeaders({ 50 | headers: { 51 | 'data-sync-client-info': Buffer.from('{"clientId":1234}', 'utf8').toString('base64') 52 | } 53 | }), {'clientId': 1234}) 54 | }) 55 | -------------------------------------------------------------------------------- /server/metrics/index.js: -------------------------------------------------------------------------------- 1 | const Prometheus = require('prom-client') 2 | const { buildPath } = require('@aerogear/data-sync-gql-core').util.graphqlPathUtil 3 | 4 | Prometheus.collectDefaultMetrics() 5 | 6 | const resolverTimingMetric = new Prometheus.Histogram({ 7 | name: 'resolver_timing_ms', 8 | help: 'Resolver response time in milliseconds', 9 | labelNames: ['datasource_type', 'operation_type', 'name'] 10 | }) 11 | 12 | const resolverRequestsMetric = new Prometheus.Counter({ 13 | name: 'requests_resolved', 14 | help: 'Number of requests resolved by server', 15 | labelNames: ['datasource_type', 'operation_type', 'path'] 16 | }) 17 | 18 | const resolverRequestsTotalMetric = new Prometheus.Counter({ 19 | name: 'requests_resolved_total', 20 | help: 'Number of requests resolved by server in total', 21 | labelNames: ['datasource_type', 'operation_type', 'path'] 22 | }) 23 | 24 | const serverResponseMetric = new Prometheus.Histogram({ 25 | name: 'server_response_ms', 26 | help: 'Server response time in milliseconds', 27 | labelNames: ['request_type', 'error'] 28 | }) 29 | 30 | exports.getMetrics = (req, res) => { 31 | res.set('Content-Type', Prometheus.register.contentType) 32 | res.end(Prometheus.register.metrics()) 33 | 34 | resolverTimingMetric.reset() 35 | resolverRequestsMetric.reset() 36 | serverResponseMetric.reset() 37 | } 38 | 39 | exports.responseLoggingMetric = (req, res, next) => { 40 | const requestMethod = req.method 41 | 42 | res['requestStart'] = Date.now() 43 | 44 | res.on('finish', onResFinished) 45 | res.on('error', onResFinished) 46 | 47 | if (next) next() 48 | 49 | function onResFinished (err) { 50 | this.removeListener('error', onResFinished) 51 | this.removeListener('finish', onResFinished) 52 | const responseTime = Date.now() - this.requestStart 53 | 54 | serverResponseMetric 55 | .labels(requestMethod, err !== undefined || res.statusCode > 299) 56 | .observe(responseTime) 57 | } 58 | } 59 | 60 | exports.updateResolverMetrics = (resolverInfo, responseTime) => { 61 | const { 62 | operation: {operation: resolverMappingType}, 63 | fieldName: resolverMappingName, 64 | path: resolverWholePath, 65 | parentType: resolverParentType, 66 | dataSourceType 67 | } = resolverInfo 68 | 69 | resolverTimingMetric 70 | .labels(dataSourceType, resolverMappingType, resolverMappingName) 71 | .observe(responseTime) 72 | 73 | resolverRequestsMetric 74 | .labels( 75 | dataSourceType, 76 | resolverMappingType, 77 | `${resolverParentType}.${buildPath(resolverWholePath)}` 78 | ) 79 | .inc(1) 80 | 81 | resolverRequestsTotalMetric 82 | .labels( 83 | dataSourceType, 84 | resolverMappingType, 85 | `${resolverParentType}.${buildPath(resolverWholePath)}` 86 | ) 87 | .inc(1) 88 | } 89 | -------------------------------------------------------------------------------- /server/security/SecurityService.js: -------------------------------------------------------------------------------- 1 | const securityServices = require('./services') 2 | class SecurityService { 3 | constructor ({ type, config }) { 4 | const SecurityServiceImplementation = securityServices[type] 5 | 6 | if (!SecurityServiceImplementation) { 7 | throw new Error(`Unsupported security service type ${type}`) 8 | } 9 | 10 | this.instance = new SecurityServiceImplementation(config) 11 | } 12 | 13 | getSchemaDirectives () { 14 | return this.instance.getSchemaDirectives() 15 | } 16 | 17 | getAuthContextProvider () { 18 | return this.instance.getAuthContextProvider() 19 | } 20 | 21 | applyAuthMiddleware (...args) { 22 | this.instance.applyAuthMiddleware(...args) 23 | } 24 | } 25 | 26 | module.exports = SecurityService 27 | -------------------------------------------------------------------------------- /server/security/services/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | keycloak: require('./keycloak/KeycloakSecurityService') 3 | } 4 | -------------------------------------------------------------------------------- /server/security/services/keycloak/AuthContextProvider.js: -------------------------------------------------------------------------------- 1 | class KeycloakAuthContextProvider { 2 | constructor (request) { 3 | this.request = request 4 | this.accessToken = (request && request.kauth && request.kauth.grant) ? request.kauth.grant.access_token : undefined 5 | this.authenticated = !!(this.accessToken) 6 | } 7 | 8 | getToken () { 9 | return this.accessToken 10 | } 11 | 12 | isAuthenticated () { 13 | return this.authenticated 14 | } 15 | 16 | getTokenContent () { 17 | return this.isAuthenticated() ? this.getToken().content : null 18 | } 19 | 20 | hasRole (role) { 21 | return this.isAuthenticated() && this.getToken().hasRole(role) 22 | } 23 | } 24 | 25 | module.exports = KeycloakAuthContextProvider 26 | -------------------------------------------------------------------------------- /server/security/services/keycloak/AuthContextProvider.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const AuthContextProvider = require('./AuthContextProvider') 3 | 4 | test('provider.getToken() returns request.kauth.grant.access_token', (t) => { 5 | const token = { 6 | someField: 'foo' 7 | } 8 | const request = { 9 | kauth: { 10 | grant: { 11 | access_token: token 12 | } 13 | } 14 | } 15 | 16 | const provider = new AuthContextProvider(request) 17 | t.truthy(provider.getToken()) 18 | t.deepEqual(provider.request, request) 19 | t.deepEqual(provider.getToken(), token) 20 | }) 21 | 22 | test('provider.getToken() returns null when request.kauth is not available', (t) => { 23 | const request = {} 24 | 25 | const provider = new AuthContextProvider(request) 26 | t.falsy(provider.getToken()) 27 | }) 28 | 29 | test('provider.isAuthenticated() returns true when there is a token', (t) => { 30 | const token = { 31 | someField: 'foo' 32 | } 33 | const request = { 34 | kauth: { 35 | grant: { 36 | access_token: token 37 | } 38 | } 39 | } 40 | 41 | const provider = new AuthContextProvider(request) 42 | t.true(provider.isAuthenticated()) 43 | }) 44 | 45 | test('provider.isAuthenticated() returns false when there is no token', (t) => { 46 | const request = {} 47 | 48 | const provider = new AuthContextProvider(request) 49 | t.false(provider.isAuthenticated()) 50 | }) 51 | 52 | test('provider.getTokenContent() returns the content when there is a token', (t) => { 53 | const token = { 54 | someField: 'foo', 55 | content: {'a': 'b'} 56 | } 57 | const request = { 58 | kauth: { 59 | grant: { 60 | access_token: token 61 | } 62 | } 63 | } 64 | 65 | const provider = new AuthContextProvider(request) 66 | t.truthy(provider.getTokenContent()) 67 | t.deepEqual(provider.getTokenContent(), {'a': 'b'}) 68 | }) 69 | 70 | test('provider.getTokenContent() returns null when there is no token', (t) => { 71 | const request = {} 72 | 73 | const provider = new AuthContextProvider(request) 74 | t.falsy(provider.getTokenContent()) 75 | }) 76 | 77 | test('provider.hasRole() returns true when there is a token and the token has the role', (t) => { 78 | const token = { 79 | someField: 'foo', 80 | content: {'a': 'b'}, 81 | hasRole: (role) => { 82 | return role === 'admin' 83 | } 84 | } 85 | const request = { 86 | kauth: { 87 | grant: { 88 | access_token: token 89 | } 90 | } 91 | } 92 | 93 | const provider = new AuthContextProvider(request) 94 | t.true(provider.hasRole('admin')) 95 | }) 96 | 97 | test('provider.hasRole() returns false when there is a token but the token dont have the role', (t) => { 98 | const token = { 99 | someField: 'foo', 100 | content: {'a': 'b'}, 101 | hasRole: (role) => { 102 | return role === 'admin' 103 | } 104 | } 105 | const request = { 106 | kauth: { 107 | grant: { 108 | access_token: token 109 | } 110 | } 111 | } 112 | 113 | const provider = new AuthContextProvider(request) 114 | t.false(provider.hasRole('super-uber-admin')) 115 | }) 116 | 117 | test('provider.hasRole() returns false when there is no token', (t) => { 118 | const request = {} 119 | 120 | const provider = new AuthContextProvider(request) 121 | t.false(provider.hasRole('foo')) 122 | }) 123 | -------------------------------------------------------------------------------- /server/security/services/keycloak/KeycloakSecurityService.js: -------------------------------------------------------------------------------- 1 | const Keycloak = require('keycloak-connect') 2 | var session = require('express-session') 3 | 4 | const schemaDirectives = require('./schemaDirectives') 5 | const AuthContextProvider = require('./AuthContextProvider') 6 | const { log } = require('../../../lib/util/logger') 7 | 8 | class KeycloakSecurityService { 9 | constructor (keycloakConfig) { 10 | this.keycloakConfig = keycloakConfig 11 | this.schemaDirectives = schemaDirectives 12 | this.AuthContextProvider = AuthContextProvider 13 | } 14 | 15 | getSchemaDirectives () { 16 | return this.schemaDirectives 17 | } 18 | 19 | getAuthContextProvider () { 20 | return this.AuthContextProvider 21 | } 22 | 23 | /** 24 | * Create keycloak middleware if needed. 25 | * 26 | * @param {*} expressRouter express router that should be used to attach auth 27 | * @param {string} apiPath location of the protected api 28 | */ 29 | applyAuthMiddleware (expressRouter, apiPath) { 30 | if (!this.keycloakConfig) { 31 | return log.info('Keycloak authentication is not configured') 32 | } 33 | 34 | log.info('Initializing Keycloak authentication') 35 | const memoryStore = new session.MemoryStore() 36 | expressRouter.use(session({ 37 | secret: this.keycloakConfig.secret || 'secret', 38 | resave: false, 39 | saveUninitialized: true, 40 | store: memoryStore 41 | })) 42 | 43 | var keycloak = new Keycloak({ 44 | store: memoryStore 45 | }, this.keycloakConfig) 46 | 47 | // Install general keycloak middleware 48 | expressRouter.use(keycloak.middleware({ 49 | admin: apiPath 50 | })) 51 | 52 | // Protect the main route for all graphql services 53 | // Disable unauthenticated access 54 | expressRouter.use(apiPath, keycloak.protect()) 55 | 56 | expressRouter.get('/token', keycloak.protect(), function (req, res) { 57 | let token = req.session['keycloak-token'] 58 | if (token) { 59 | return res.json({ 60 | 'Authorization': 'Bearer ' + JSON.parse(token).access_token 61 | }) 62 | } 63 | res.json({}) 64 | }) 65 | } 66 | } 67 | 68 | module.exports = KeycloakSecurityService 69 | -------------------------------------------------------------------------------- /server/security/services/keycloak/schemaDirectives/hasRole.js: -------------------------------------------------------------------------------- 1 | const { SchemaDirectiveVisitor } = require('graphql-tools') 2 | const { defaultFieldResolver } = require('graphql') 3 | const { ForbiddenError } = require('apollo-server-express') 4 | const newInternalServerError = require('../../../../lib/util/internalServerError') 5 | const Joi = require('joi') 6 | const { log } = require('../../../../lib/util/logger') 7 | 8 | class HasRoleDirective extends SchemaDirectiveVisitor { 9 | visitFieldDefinition (field) { 10 | const { resolve = defaultFieldResolver } = field 11 | const { error, value } = this.validateArgs() 12 | 13 | const { roles } = value 14 | 15 | field.resolve = async function (root, args, context, info) { 16 | // must check for a validation error at runtime 17 | // to ensure an appropriate message is sent back 18 | log.info(`checking user is authorized to access ${field.name} on parent ${info.parentType.name}. Must have one of [${roles}]`) 19 | 20 | if (error) { 21 | log.error(`Invalid hasRole directive on field ${field.name} on parent ${info.parentType.name}`, error) 22 | throw newInternalServerError(context) 23 | } 24 | 25 | if (!context.auth || !context.auth.isAuthenticated()) { 26 | const AuthorizationErrorMessage = `Unable to find authentication. Authorization is required for field ${field.name} on parent ${info.parentType.name}. Must have one of the following roles: [${roles}]` 27 | log.error({ error: AuthorizationErrorMessage }) 28 | throw new ForbiddenError(AuthorizationErrorMessage) 29 | } 30 | 31 | let foundRole = null // this will be the role the user was successfully authorized on 32 | 33 | foundRole = roles.find((role) => { 34 | return context.auth.hasRole(role) 35 | }) 36 | 37 | if (!foundRole) { 38 | const AuthorizationErrorMessage = `user is not authorized for field ${field.name} on parent ${info.parentType.name}. Must have one of the following roles: [${roles}]` 39 | log.error({ error: AuthorizationErrorMessage, details: context.auth.getTokenContent() }) 40 | throw new ForbiddenError(AuthorizationErrorMessage) 41 | } 42 | 43 | log.info(`user successfully authorized with role: ${foundRole}`) 44 | 45 | // Return appropriate error if this is false 46 | const result = await resolve.apply(this, [root, args, context, info]) 47 | return result 48 | } 49 | } 50 | 51 | validateArgs () { 52 | // joi is dope. Read the docs and discover the magic. 53 | // https://github.com/hapijs/joi/blob/master/API.md 54 | const argsSchema = Joi.object({ 55 | role: Joi.array().required().items(Joi.string()).single() 56 | }) 57 | 58 | const result = argsSchema.validate(this.args) 59 | 60 | // result.value.role will be an array so it makes sense to add the roles alias 61 | result.value.roles = result.value.role 62 | return result 63 | } 64 | } 65 | 66 | module.exports = HasRoleDirective 67 | -------------------------------------------------------------------------------- /server/security/services/keycloak/schemaDirectives/hasRole.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('ava') 2 | const HasRoleDirective = require('./hasRole') 3 | const KeycloakAuthContextProvider = require('../AuthContextProvider') 4 | 5 | test('context.auth.hasRole() is called', async (t) => { 6 | t.plan(3) 7 | const directiveArgs = { 8 | role: 'admin' 9 | } 10 | 11 | const directive = new HasRoleDirective({ 12 | name: 'testHasRoleDirective', 13 | args: directiveArgs 14 | }) 15 | 16 | const field = { 17 | resolve: (root, args, context, info) => { 18 | t.pass() 19 | }, 20 | name: 'testField' 21 | } 22 | 23 | directive.visitFieldDefinition(field) 24 | 25 | const root = {} 26 | const args = {} 27 | const req = { 28 | kauth: { 29 | grant: { 30 | access_token: { 31 | hasRole: (role) => { 32 | t.pass() 33 | t.deepEqual(role, directiveArgs.role) 34 | return true 35 | } 36 | } 37 | } 38 | } 39 | } 40 | const context = { 41 | request: req, 42 | auth: new KeycloakAuthContextProvider(req) 43 | } 44 | 45 | const info = { 46 | parentType: { 47 | name: 'testParent' 48 | } 49 | } 50 | 51 | await field.resolve(root, args, context, info) 52 | }) 53 | 54 | test('visitFieldDefinition accepts an array of roles', async (t) => { 55 | t.plan(4) 56 | const directiveArgs = { 57 | role: ['foo', 'bar', 'baz'] 58 | } 59 | 60 | const directive = new HasRoleDirective({ 61 | name: 'testHasRoleDirective', 62 | args: directiveArgs 63 | }) 64 | 65 | const field = { 66 | resolve: (root, args, context, info) => { 67 | t.pass() 68 | }, 69 | name: 'testField' 70 | } 71 | 72 | directive.visitFieldDefinition(field) 73 | 74 | const root = {} 75 | const args = {} 76 | const req = { 77 | kauth: { 78 | grant: { 79 | access_token: { 80 | hasRole: (role) => { 81 | t.log(`checking has role ${role}`) 82 | t.pass() 83 | return (role === 'baz') // this makes sure it doesn't return true instantly 84 | } 85 | } 86 | } 87 | } 88 | } 89 | const context = { 90 | request: req, 91 | auth: new KeycloakAuthContextProvider(req) 92 | } 93 | 94 | const info = { 95 | parentType: { 96 | name: 'testParent' 97 | } 98 | } 99 | 100 | await field.resolve(root, args, context, info) 101 | }) 102 | 103 | test('if there is no authentication, then an error is returned and the original resolver will not execute', async (t) => { 104 | const directiveArgs = { 105 | role: 'admin' 106 | } 107 | 108 | const directive = new HasRoleDirective({ 109 | name: 'testHasRoleDirective', 110 | args: directiveArgs 111 | }) 112 | 113 | const field = { 114 | resolve: (root, args, context, info) => { 115 | return new Promise((resolve, reject) => { 116 | t.fail('the original resolver should never be called when an auth error is thrown') 117 | return reject(new Error('the original resolver should never be called when an auth error is thrown')) 118 | }) 119 | }, 120 | name: 'testField' 121 | } 122 | 123 | directive.visitFieldDefinition(field) 124 | 125 | const root = {} 126 | const args = {} 127 | const req = {} 128 | const context = { 129 | request: req, 130 | auth: new KeycloakAuthContextProvider(req) 131 | } 132 | 133 | const info = { 134 | parentType: { 135 | name: 'testParent' 136 | } 137 | } 138 | 139 | await t.throws(async () => { 140 | await field.resolve(root, args, context, info) 141 | }, `Unable to find authentication. Authorization is required for field ${field.name} on parent ${info.parentType.name}. Must have one of the following roles: [${directiveArgs.role}]`) 142 | }) 143 | 144 | test('if token does not have the required role, then an error is returned and the original resolver will not execute', async (t) => { 145 | const directiveArgs = { 146 | role: 'admin' 147 | } 148 | 149 | const directive = new HasRoleDirective({ 150 | name: 'testHasRoleDirective', 151 | args: directiveArgs 152 | }) 153 | 154 | const field = { 155 | resolve: (root, args, context, info) => { 156 | return new Promise((resolve, reject) => { 157 | t.fail('the original resolver should never be called when an auth error is thrown') 158 | return reject(new Error('the original resolver should never be called when an auth error is thrown')) 159 | }) 160 | }, 161 | name: 'testField' 162 | } 163 | 164 | directive.visitFieldDefinition(field) 165 | 166 | const root = {} 167 | const args = {} 168 | const req = { 169 | kauth: { 170 | grant: { 171 | access_token: { 172 | hasRole: (role) => { 173 | t.deepEqual(role, directiveArgs.role) 174 | return false 175 | } 176 | } 177 | } 178 | } 179 | } 180 | const context = { 181 | request: req, 182 | auth: new KeycloakAuthContextProvider(req) 183 | } 184 | 185 | const info = { 186 | parentType: { 187 | name: 'testParent' 188 | } 189 | } 190 | 191 | await t.throws(async () => { 192 | await field.resolve(root, args, context, info) 193 | }, `user is not authorized for field ${field.name} on parent ${info.parentType.name}. Must have one of the following roles: [${directiveArgs.role}]`) 194 | }) 195 | 196 | test('if hasRole arguments are invalid, visitSchemaDirective does not throw, but field.resolve will return a generic error to the user and original resolver will not be called', async (t) => { 197 | const directiveArgs = { 198 | role: 'admin', 199 | some: 'unknown arg' 200 | } 201 | 202 | const directive = new HasRoleDirective({ 203 | name: 'testHasRoleDirective', 204 | args: directiveArgs 205 | }) 206 | 207 | const field = { 208 | resolve: (root, args, context, info) => { 209 | return new Promise((resolve, reject) => { 210 | t.fail('the original resolver should never be called when an auth error is thrown') 211 | return reject(new Error('the original resolver should never be called when an auth error is thrown')) 212 | }) 213 | }, 214 | name: 'testField' 215 | } 216 | 217 | t.notThrows(() => { 218 | directive.visitFieldDefinition(field) 219 | }) 220 | 221 | const root = {} 222 | const args = {} 223 | const req = { 224 | id: '123', 225 | kauth: { 226 | grant: { 227 | access_token: { 228 | hasRole: (role) => { 229 | t.deepEqual(role, directiveArgs.role) 230 | return false 231 | } 232 | } 233 | } 234 | } 235 | } 236 | const context = { 237 | request: req, 238 | auth: new KeycloakAuthContextProvider(req) 239 | } 240 | 241 | const info = { 242 | parentType: { 243 | name: 'testParent' 244 | } 245 | } 246 | 247 | await t.throws(async () => { 248 | await field.resolve(root, args, context, info) 249 | }, `An internal server error occurred, please contact the server administrator and provide the following id: ${context.request.id}`) 250 | }) 251 | -------------------------------------------------------------------------------- /server/security/services/keycloak/schemaDirectives/index.js: -------------------------------------------------------------------------------- 1 | const hasRole = require('./hasRole') 2 | 3 | const directives = { 4 | hasRole 5 | } 6 | 7 | module.exports = directives 8 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const _ = require('lodash') 3 | 4 | // server 5 | const newExpressApp = require('./expressApp') 6 | const newApolloServer = require('./apolloServer') 7 | const SecurityService = require('./security/SecurityService') 8 | 9 | // middlewares 10 | const { log } = require('./lib/util/logger') 11 | const expressPino = require('express-pino-logger')({ logger: log }) 12 | const { getMetrics, responseLoggingMetric } = require('./metrics') 13 | 14 | if (process.env.LOG_LEVEL) { 15 | expressPino.logger.level = process.env.LOG_LEVEL 16 | } else { 17 | expressPino.logger.level = 'debug' 18 | } 19 | 20 | // schema 21 | const schemaListenerCreator = require('./lib/schemaListeners/schemaListenerCreator') 22 | const schemaName = 'default' 23 | 24 | class DataSyncServer { 25 | constructor (config, models, pubsub, core) { 26 | this.config = config 27 | this.models = models 28 | this.pubsub = pubsub 29 | this.core = core 30 | this.schema = null 31 | this.dataSources = null 32 | this.schemaName = schemaName 33 | } 34 | 35 | async initialize () { 36 | this.server = http.createServer() 37 | 38 | // get some options 39 | const { 40 | graphQLConfig, 41 | playgroundConfig, 42 | schemaListenerConfig, 43 | keycloakConfig, 44 | securityServiceConfig, 45 | serverSecurity 46 | } = this.config 47 | 48 | let securityService 49 | let schemaDirectives 50 | 51 | if (securityServiceConfig.type && securityServiceConfig.config) { 52 | securityService = new SecurityService(securityServiceConfig) 53 | schemaDirectives = securityService.getSchemaDirectives() 54 | } 55 | 56 | const serverConfig = { 57 | expressAppOptions: { 58 | keycloakConfig: keycloakConfig, 59 | graphqlEndpoint: graphQLConfig.graphqlEndpoint, 60 | models: this.models 61 | }, 62 | expressAppMiddlewares: { 63 | metrics: getMetrics, 64 | responseLoggingMetric, 65 | logging: expressPino 66 | }, 67 | serverSecurity: serverSecurity, 68 | securityService: securityService, 69 | schemaDirectives: schemaDirectives, 70 | graphQLConfig: graphQLConfig, 71 | playgroundConfig: playgroundConfig, 72 | schemaListenerConfig: schemaListenerConfig 73 | } 74 | 75 | this.serverConfig = serverConfig 76 | 77 | // generate the GraphQL Schema 78 | const { schema, dataSources } = await this.core.buildSchema(this.schemaName, this.pubsub, this.serverConfig.schemaDirectives) 79 | this.schema = schema 80 | this.dataSources = dataSources 81 | 82 | await this.core.connectActiveDataSources(this.dataSources) 83 | 84 | this.newServer() 85 | 86 | function startListening (port) { 87 | var server = this 88 | return new Promise((resolve) => { 89 | server.listen(port, resolve) 90 | }) 91 | } 92 | 93 | this.server.startListening = startListening.bind(this.server) 94 | 95 | // Initialize the schema listener for hot reload 96 | this.schemaListener = schemaListenerCreator(this.serverConfig.schemaListenerConfig) 97 | this.debouncedOnSchemaRebuild = _.debounce(this.onSchemaChangedNotification, 500).bind(this) 98 | this.schemaListener.start(this.debouncedOnSchemaRebuild) 99 | } 100 | 101 | /** 102 | * Starts or restarts express app and Apollo server 103 | */ 104 | newServer () { 105 | // Initialize an express app, apply the apollo middleware, and mount the app to the http server 106 | this.app = newExpressApp(this.serverConfig.expressAppOptions, this.serverConfig.expressAppMiddlewares, this.serverConfig.securityService) 107 | this.apolloServer = newApolloServer(this.app, this.schema, this.server, this.serverConfig.tracing, this.serverConfig.playgroundConfig, this.serverConfig.graphQLConfig.graphqlEndpoint, this.serverConfig.securityService, this.serverConfig.serverSecurity) 108 | this.server.on('request', this.app) 109 | } 110 | 111 | async cleanup () { 112 | await this.models.sequelize.close() 113 | if (this.schemaListener) await this.schemaListener.stop() 114 | if (this.dataSources) await this.core.disconnectActiveDataSources(this.dataSources) 115 | if (this.server) await this.server.close() 116 | } 117 | 118 | async onSchemaChangedNotification () { 119 | log.info('Received schema change notification. Rebuilding it') 120 | let newSchema 121 | try { 122 | newSchema = await this.core.buildSchema(this.schemaName, this.pubsub, this.serverConfig.schemaDirectives) 123 | } catch (ex) { 124 | log.error('Error while reloading config') 125 | log.error(ex) 126 | log.error('Will continue using the old config') 127 | } 128 | 129 | if (newSchema) { 130 | // first do some cleaning up 131 | this.apolloServer.subscriptionServer.close() 132 | this.server.removeListener('request', this.app) 133 | // reinitialize the server objects 134 | this.schema = newSchema.schema 135 | this.newServer() 136 | 137 | try { 138 | await this.core.disconnectActiveDataSources(this.dataSources) // disconnect existing ones first 139 | } catch (ex) { 140 | log.error('Error while disconnecting previous data sources') 141 | log.error(ex) 142 | log.error('Will continue connecting to new ones') 143 | } 144 | 145 | try { 146 | await this.core.connectActiveDataSources(newSchema.dataSources) 147 | this.dataSources = newSchema.dataSources 148 | } catch (ex) { 149 | log.error('Error while connecting to new data sources') 150 | log.error(ex) 151 | log.error('Will use the old schema and the data sources') 152 | try { 153 | await this.core.connectActiveDataSources(this.dataSources) 154 | } catch (ex) { 155 | log.error('Error while connecting to previous data sources') 156 | log.error(ex) 157 | } 158 | } 159 | } 160 | } 161 | } 162 | 163 | module.exports = DataSyncServer 164 | --------------------------------------------------------------------------------