├── .env
├── client
├── .env
├── src
│ ├── react-app-env.d.ts
│ ├── config
│ │ ├── index.ts
│ │ ├── SimpleSchema.ts
│ │ ├── conflictStrategy.ts
│ │ └── clientConfig.ts
│ ├── hooks
│ │ ├── index.ts
│ │ └── useSubscribeToMore.ts
│ ├── pages
│ │ ├── index.ts
│ │ ├── OfflineQueuePage.tsx
│ │ ├── AddTaskPage.tsx
│ │ ├── TaskPage.tsx
│ │ ├── ProfilePage.tsx
│ │ ├── UpdateTaskPage.tsx
│ │ └── ViewTaskPage.tsx
│ ├── auth
│ │ ├── KeycloakRoute.tsx
│ │ └── keycloakAuth.ts
│ ├── components
│ │ ├── Loading.tsx
│ │ ├── NetworkBadge.tsx
│ │ ├── index.ts
│ │ ├── Empty.tsx
│ │ ├── OfflineQueueBadge.tsx
│ │ ├── Router.tsx
│ │ ├── OfflineList.tsx
│ │ ├── TaskList.tsx
│ │ ├── Task.tsx
│ │ └── Header.tsx
│ ├── AuthContext.tsx
│ ├── index.tsx
│ ├── helpers
│ │ ├── index.ts
│ │ ├── ConflictLogger.ts
│ │ ├── mutationOptions.ts
│ │ ├── subscriptionOptions.ts
│ │ └── CapacitorNetworkStatus.ts
│ ├── theme
│ │ ├── index.ts
│ │ ├── styles.css
│ │ └── variables.css
│ ├── forms
│ │ ├── TaskForm.tsx
│ │ └── task.ts
│ ├── App.tsx
│ ├── declarations.ts
│ ├── AppContainer.tsx
│ ├── graphql
│ │ └── generated.ts
│ └── serviceWorker.ts
├── public
│ ├── assets
│ │ ├── icon
│ │ │ ├── icon.png
│ │ │ ├── aerogear.png
│ │ │ ├── favicon.png
│ │ │ ├── keycloak.png
│ │ │ └── avatar.svg
│ │ └── shapes.svg
│ ├── keycloak.example.json
│ ├── manifest.json
│ └── index.html
├── ionic.config.json
├── capacitor.config.json
├── .gitignore
├── tsconfig.json
├── README.adoc
└── package.json
├── server
├── .dockerignore
├── .gitignore
├── src
│ ├── resolvers
│ │ ├── custom.ts
│ │ └── scalars.ts
│ ├── config
│ │ ├── _keycloak.json
│ │ ├── auth.ts
│ │ ├── playground.gql
│ │ └── config.ts
│ ├── db.ts
│ ├── pubsub.ts
│ ├── index.ts
│ ├── graphql.ts
│ ├── auth.ts
│ ├── crudServiceCreator.ts
│ └── schema
│ │ └── schema.graphql
├── Dockerfile
├── integrations
│ ├── mqtt
│ │ ├── docker-compose.yml
│ │ └── configureAMQ.js
│ └── keycloak
│ │ ├── docker-compose.yml
│ │ ├── getToken.js
│ │ └── initKeycloak.js
├── .env
├── docker-compose.yml
├── README.md
├── model
│ └── task.graphql
├── tsconfig.json
└── package.json
├── walkthroughs-config.json
├── walkthroughs
└── 1-exploring-datasync-codeready
│ ├── images
│ └── arch.png
│ ├── walkthrough.json
│ └── walkthrough.adoc
├── renovate.json
├── scripts
├── create_walkthrough.sh
└── publish_container.sh
├── .github
├── ISSUE_TEMPLATE
│ ├── 3-ask-for-help.md
│ ├── 1-bug-report.md
│ └── 2-feature-request.md
└── PULL_REQUEST_TEMPLATE.md
├── .gitignore
├── .vscode
└── launch.json
├── .graphqlrc.yml
├── LICENSE
├── package.json
├── .openshift
├── amq-topics.yml
├── README.md
└── datasync-app-template.yml
├── .circleci
└── config.yml
├── devfile.yaml
└── README.adoc
/.env:
--------------------------------------------------------------------------------
1 | SKIP_PREFLIGHT_CHECK=true
--------------------------------------------------------------------------------
/client/.env:
--------------------------------------------------------------------------------
1 | SKIP_PREFLIGHT_CHECK=true
--------------------------------------------------------------------------------
/server/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
--------------------------------------------------------------------------------
/client/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/src/config/index.ts:
--------------------------------------------------------------------------------
1 | export { clientConfig } from './clientConfig';
2 |
--------------------------------------------------------------------------------
/client/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { useSubscribeToMore } from './useSubscribeToMore';
--------------------------------------------------------------------------------
/walkthroughs-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "prettyName": "Data Sync Solution Pattern"
3 | }
4 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | types
4 | yarn.lock
5 | yarn-error.log
6 | data/*
7 |
--------------------------------------------------------------------------------
/client/public/assets/icon/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aerogear/datasync-starter/HEAD/client/public/assets/icon/icon.png
--------------------------------------------------------------------------------
/client/public/assets/icon/aerogear.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aerogear/datasync-starter/HEAD/client/public/assets/icon/aerogear.png
--------------------------------------------------------------------------------
/client/public/assets/icon/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aerogear/datasync-starter/HEAD/client/public/assets/icon/favicon.png
--------------------------------------------------------------------------------
/client/public/assets/icon/keycloak.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aerogear/datasync-starter/HEAD/client/public/assets/icon/keycloak.png
--------------------------------------------------------------------------------
/client/ionic.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "appId": "com.datasync.starter",
4 | "integrations": {
5 | "capacitor": {}
6 | },
7 | "type": "react"
8 | }
9 |
--------------------------------------------------------------------------------
/walkthroughs/1-exploring-datasync-codeready/images/arch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aerogear/datasync-starter/HEAD/walkthroughs/1-exploring-datasync-codeready/images/arch.png
--------------------------------------------------------------------------------
/server/src/resolvers/custom.ts:
--------------------------------------------------------------------------------
1 |
2 |
3 | /**
4 | * Custom resolvers for business logic that is not supported out of the box
5 | * by Graphback.
6 | */
7 | export default {
8 |
9 | }
--------------------------------------------------------------------------------
/walkthroughs/1-exploring-datasync-codeready/walkthrough.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "repos": [],
4 | "managedServices": [
5 | ],
6 | "serviceInstances": []
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/client/capacitor.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "appId": "io.ionic.starter",
3 | "appName": "client",
4 | "bundledWebRuntime": true,
5 | "npmClient": "npm",
6 | "webDir": "build",
7 | "cordova": {}
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/config/SimpleSchema.ts:
--------------------------------------------------------------------------------
1 | import SimpleSchema from "simpl-schema";
2 |
3 | // add the uniforms property to SimpleSchema
4 | SimpleSchema.extendOptions(['uniforms']);
5 |
6 | export default SimpleSchema;
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:8
2 |
3 | # Create app directory
4 | WORKDIR /usr/src/app
5 |
6 | COPY . .
7 | RUN npm install
8 |
9 | VOLUME ./files
10 |
11 | EXPOSE 4000
12 | CMD [ "npm", "start" ]
13 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "config:base",
4 | ":pinVersions"
5 | ],
6 | "groupName": "all",
7 | "automerge": true,
8 | "major": {
9 | "automerge": false
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/config/_keycloak.json:
--------------------------------------------------------------------------------
1 | {
2 | "realm": "datasync-starter",
3 | "bearer-only": true,
4 | "auth-server-url": "http://localhost:8080/auth/",
5 | "ssl-required": "external",
6 | "resource": "datasync-starter-server",
7 | "confidential-port": 0
8 | }
--------------------------------------------------------------------------------
/server/integrations/mqtt/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | # Mosca is a simple MQTT Broker
4 | # In OpenShift/Production we would use the Red Hat AMQ broker
5 | mosca:
6 | image: eclipse-mosquitto:latest
7 | ports:
8 | - "1883:1883" # MQTT
--------------------------------------------------------------------------------
/client/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | export { TaskPage } from './TaskPage';
2 | export { ProfilePage } from './ProfilePage';
3 | export { AddTaskPage } from './AddTaskPage';
4 | export { UpdateTaskPage } from './UpdateTaskPage';
5 | export { OfflineQueuePage } from './OfflineQueuePage';
6 |
7 |
--------------------------------------------------------------------------------
/client/public/keycloak.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "realm": "",
3 | "auth-server-url": "https://your-server/auth",
4 | "ssl-required": "none",
5 | "resource": "",
6 | "public-client": true,
7 | "use-resource-role-mappings": true,
8 | "confidential-port": 0
9 | }
--------------------------------------------------------------------------------
/server/integrations/keycloak/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | keycloak:
5 | image: jboss/keycloak:10.0.2
6 | ports:
7 | - "8080:8080"
8 | environment:
9 | DB_VENDOR: h2
10 | KEYCLOAK_USER: admin
11 | KEYCLOAK_PASSWORD: admin
--------------------------------------------------------------------------------
/client/src/auth/KeycloakRoute.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Route } from 'react-router-dom'
3 |
4 | export const KeycloakRoute: React.FC = ({ component: Component, ...rest }) => {
5 | return (
6 |
10 | );
11 | };
12 |
--------------------------------------------------------------------------------
/server/src/config/auth.ts:
--------------------------------------------------------------------------------
1 | import { CrudServicesAuthConfig } from "@graphback/keycloak-authz"
2 |
3 | export const authConfig: CrudServicesAuthConfig = {
4 | Task: {
5 | create: { roles: [] },
6 | read: { roles: [] },
7 | update: { roles: [] },
8 | delete: { roles: ['admin'] },
9 | }
10 | }
--------------------------------------------------------------------------------
/client/public/assets/icon/avatar.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/components/Loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IonLoading } from '@ionic/react';
3 | import { ILoadingProps } from '../declarations';
4 |
5 | export const Loading: React.FC = ({ loading }) => {
6 | return ;
10 | };
--------------------------------------------------------------------------------
/client/src/AuthContext.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { KeycloakInstance, KeycloakProfile } from 'keycloak-js';
3 |
4 | export interface IAuthContext {
5 | keycloak?: KeycloakInstance | undefined
6 | profile?: KeycloakProfile | undefined
7 | }
8 |
9 | export const AuthContext = React.createContext({ keycloak: undefined });
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { App } from './App';
4 | import { AppContainer } from './AppContainer';
5 | import * as serviceWorker from './serviceWorker';
6 |
7 | ReactDOM.render(, document.getElementById('root'));
8 |
9 | serviceWorker.register();
10 |
--------------------------------------------------------------------------------
/server/src/resolvers/scalars.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DateTimeResolver,
3 | JSONResolver,
4 | ObjectIDResolver
5 | } from 'graphql-scalars';
6 |
7 |
8 | /**
9 | * Default scalars that are supported by DataSync starter
10 | */
11 | export default {
12 | ObjectID: ObjectIDResolver,
13 | DateTime: DateTimeResolver,
14 | JSON: JSONResolver
15 | }
--------------------------------------------------------------------------------
/client/src/helpers/index.ts:
--------------------------------------------------------------------------------
1 | import * as mutationOptions from './mutationOptions';
2 | import * as subscriptionOptions from './subscriptionOptions';
3 |
4 | export { ConflictLogger } from './ConflictLogger';
5 |
6 | const { globalCacheUpdates } = mutationOptions;
7 |
8 | export {
9 | mutationOptions,
10 | globalCacheUpdates,
11 | subscriptionOptions,
12 | };
13 |
--------------------------------------------------------------------------------
/client/src/hooks/useSubscribeToMore.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | export const useSubscribeToMore: any = ({ options, subscribeToMore } : { options: any, subscribeToMore: Function }) => {
4 |
5 | useEffect(()=>{
6 | options.forEach((option: any) => {
7 | subscribeToMore(option);
8 | });
9 | }, [options, subscribeToMore]);
10 |
11 | };
12 |
--------------------------------------------------------------------------------
/server/.env:
--------------------------------------------------------------------------------
1 | ## Mongo
2 | MONGO_USER=user
3 | MONGO_PASSWORD=password
4 | MONGO_ADMIN_PASSWORD=password
5 | MONGO_DATABASE=showcase
6 | MONGO_HOST=
7 |
8 | ## MQTT
9 | MQTT_HOST=
10 | MQTT_PORT=
11 | MQTT_PASSWORD=
12 | MQTT_USERNAME=
13 | MQTT_PROTOCOL=
14 |
15 | # Hack to enable keycloak with self signed certs
16 | # don't do it in production
17 | NODE_TLS_REJECT_UNAUTHORIZED=0
--------------------------------------------------------------------------------
/server/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | mongodb:
4 | image: centos/mongodb-34-centos7
5 | container_name: "mongodb"
6 | environment:
7 | - MONGODB_USER=user
8 | - MONGODB_PASSWORD=password
9 | - MONGODB_ADMIN_PASSWORD=password
10 | - MONGODB_DATABASE=showcase
11 | ports:
12 | - 27017:27017
--------------------------------------------------------------------------------
/client/src/components/NetworkBadge.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IonBadge } from '@ionic/react';
3 |
4 | export const NetworkBadge: React.FC<{ isOnline?: boolean}> = ({ isOnline }) => {
5 |
6 | return (isOnline)
7 | ?Online
8 | :Offline;
9 |
10 | };
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 | .vscode
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/client/src/components/index.ts:
--------------------------------------------------------------------------------
1 |
2 | export { Task } from './Task';
3 | export { Empty } from './Empty';
4 | export { Header } from './Header';
5 | export { Router } from './Router';
6 | export { Loading } from './Loading';
7 | export { TaskList } from './TaskList';
8 | export { OfflineList } from './OfflineList';
9 | export { KeycloakRoute } from '../auth/KeycloakRoute';
10 | export { NetworkBadge } from './NetworkBadge';
11 | export { OfflineQueueBadge } from './OfflineQueueBadge';
--------------------------------------------------------------------------------
/scripts/create_walkthrough.sh:
--------------------------------------------------------------------------------
1 |
2 | set -e
3 |
4 | echo "Creating branch. "
5 | echo "This command will reset your master branch and remove generated files"
6 | echo "Press any key or cancel"
7 |
8 | read choice
9 |
10 | git checkout master
11 | git reset --hard origin/master
12 | rm -Rf ./client/src/graphql
13 | rm -Rf ./server/src/resolvers
14 | rm -Rf ./server/src/schema
15 | git add --all
16 | git commit -m"Walktrough cleanup"
17 | git push origin +master:walkthrough
18 |
19 | echo "Successfully created branch"
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/3-ask-for-help.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "⁉️ Need some help with this project?"
3 | about: Please file an issue in our repo.
4 |
5 | ---
6 |
7 | ## Help Wanted
8 |
9 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/client/src/pages/OfflineQueuePage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useApolloOfflineClient } from 'react-offix-hooks';
3 | import { Header, OfflineList } from '../components';
4 | import { RouteComponentProps } from 'react-router';
5 |
6 | export const OfflineQueuePage: React.FC = ({ match }) => {
7 |
8 | const { queue } = useApolloOfflineClient();
9 |
10 | return (
11 | <>
12 |
13 |
14 | >
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/server/integrations/mqtt/configureAMQ.js:
--------------------------------------------------------------------------------
1 | const execSync = require("child_process").execSync;
2 |
3 | const AMQURL = execSync(
4 | `oc get addressspace datasync -o jsonpath='{.status.endpointStatuses[?(@.name=="messaging")].externalHost}'`,
5 | { encoding: "utf8" }
6 | );
7 |
8 |
9 | const message = `
10 | Obtained your credentials from the AMQ server.
11 | Please update your .env file with following config:
12 |
13 | MQTT_HOST = ${AMQURL}
14 | MQTT_PORT = 443
15 | MQTT_PASSWORD = Password1
16 | MQTT_USERNAME = messaging-user
17 | MQTT_PROTOCOL = tls
18 | `
19 | console.log(message);
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
7 |
8 | ### Description
9 |
10 |
11 |
12 | ##### Checklist
13 |
14 |
15 | - [ ] `npm test` passes
16 | - [ ] `npm run build` works
17 | - [ ] tests are included
18 | - [ ] documentation is changed or added
19 |
--------------------------------------------------------------------------------
/client/src/components/Empty.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IonContent, IonGrid, IonRow, IonCol, IonText } from '@ionic/react';
3 |
4 | export const Empty: React.FC = ({ message }) => {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 | { message }
12 |
13 |
14 |
15 |
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/server/src/config/playground.gql:
--------------------------------------------------------------------------------
1 | mutation create {
2 | createTask(input: { title: "test",
3 | description: "" }) {
4 | id
5 | }
6 | }
7 |
8 |
9 | mutation update {
10 | updateTask(input:{id:"5ee7a3e5e1479c83e4f1748f",
11 | title: "test", description: ""}){
12 | id
13 | type
14 | }
15 | }
16 |
17 |
18 |
19 | query syncTasks{
20 | syncTasks(lastSync: "1592239061067"){
21 | items{
22 | id
23 | title
24 | }
25 | lastSync
26 | }
27 | }
28 |
29 |
30 | query find {
31 | findTasks{
32 | items{
33 | id
34 | title
35 | }
36 | }
37 | }
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "react"
21 | },
22 | "include": [
23 | "src"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/1-bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F41B Bug report"
3 | about: Create a report to help us improve
4 |
5 | ---
6 |
7 | ## Bug Report
8 |
9 |
17 |
18 | * **Version/Commit**:
19 | * **Ionic Version**:
20 | * **Node.js / npm versions**:
21 |
22 |
--------------------------------------------------------------------------------
/client/src/config/conflictStrategy.ts:
--------------------------------------------------------------------------------
1 |
2 | import { ObjectState, ConflictResolutionData } from "offix-client";
3 |
4 | export class TimeStampState implements ObjectState {
5 |
6 | public assignServerState(client: any, server: any): void {
7 | client.updatedAt = server.updatedAt.toString();
8 | }
9 | public hasConflict(client: any, server: any): boolean {
10 | return client.updatedAt !== server.updatedAt;
11 | }
12 | public getStateFields(): string[] {
13 | return ["updatedAt"];
14 | }
15 |
16 | public currentState(currentObjectState: ConflictResolutionData) {
17 | return currentObjectState.updatedAt;
18 | }
19 | }
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/2-feature-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F680 Feature request"
3 | about: Suggest an idea for this project
4 |
5 | ---
6 |
7 | ## Feature Request
8 |
9 |
14 |
15 | **Is your feature request related to a problem? Please describe.**
16 | Please describe the problem you are trying to solve.
17 |
18 | **Describe the solution you'd like**
19 | Please describe the desired behavior.
20 |
21 | **Describe alternatives you've considered**
22 | Please describe alternative solutions or features you have considered.
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Specifies intentionally untracked files to ignore when using Git
2 | # http://git-scm.com/docs/gitignore
3 |
4 | *~
5 | *.sw[mnpcod]
6 | *.log
7 | *.tmp
8 | *.tmp.*
9 | log.txt
10 | *.sublime-project
11 | *.sublime-workspace
12 | npm-debug.log*
13 |
14 | .idea/
15 | .ionic/
16 | .sourcemaps/
17 | .sass-cache/
18 | .tmp/
19 | .versions/
20 | coverage/
21 | www/
22 | node_modules/
23 | tmp/
24 | temp/
25 | platforms/
26 | plugins/
27 | plugins/android.json
28 | plugins/ios.json
29 | $RECYCLE.BIN/
30 |
31 | .DS_Store
32 | Thumbs.db
33 | UserInterfaceState.xcuserstate
34 | package-lock.json
35 |
36 | server/website
37 | yarn.lock
38 |
39 | client/ios/
40 | client/android/
41 | client/.gradle/
--------------------------------------------------------------------------------
/server/integrations/keycloak/getToken.js:
--------------------------------------------------------------------------------
1 | const tokenRequester = require('keycloak-request-token')
2 |
3 | const username = process.argv[2]
4 | const password = process.argv[3]
5 |
6 | const baseUrl = 'http://localhost:8080/auth';
7 | const settings = {
8 | username: username || 'developer',
9 | password: password || 'developer',
10 | grant_type: 'password',
11 | client_id: 'datasync-starter-client',
12 | realmName: 'datasync-starter'
13 | }
14 |
15 | tokenRequester(baseUrl, settings)
16 | .then((token) => {
17 | const headers = {
18 | Authorization: `Bearer ${token}`
19 | }
20 | console.log(JSON.stringify(headers))
21 | }).catch((err) => {
22 | console.log('err', err)
23 | })
--------------------------------------------------------------------------------
/server/README.md:
--------------------------------------------------------------------------------
1 | # DataSync Full Stack Server
2 |
3 | Node.js template using Graphback
4 |
5 | ## Integrations
6 |
7 | - Graphback (Apollo GraphQL template)
8 | - Keycloak (Authentication)
9 | - AMQ Online (MQTT)
10 | - MongoDB
11 |
12 | ## Usage
13 |
14 | This project has been created using Graphback.
15 | Run the project using the following steps:
16 |
17 | - Install
18 |
19 | ```sh
20 | yarn install
21 | ```
22 |
23 | - Start the Mongo database and MQTT client
24 |
25 | ```sh
26 | docker-compose up -d
27 | ```
28 |
29 | - Generate resources(schema and resolvers) and create database
30 |
31 | ```sh
32 | yarn graphback generate
33 | ```
34 |
35 | - Start the server
36 |
37 | ```sh
38 | yarn start:server
39 | ```
40 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "node",
9 | "request": "launch",
10 | "name": "Launch server",
11 | "args": ["${workspaceFolder}/server/src/index.ts"],
12 | "sourceMaps": true,
13 | "runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
14 | "outFiles": ["${workspaceFolder}/server/dist/**/*.js"],
15 | "cwd": "${workspaceRoot}/server",
16 | "protocol": "inspector",
17 | }
18 | ]
19 | }
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "AeroGear DataSync Starter",
3 | "name": "AeroGear DataSync Starter",
4 | "icons": [
5 | {
6 | "src": "assets/icon/aerogear.png",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "assets/icon/aerogear.png",
12 | "type": "image/png",
13 | "sizes": "512x512",
14 | "purpose": "maskable"
15 | },
16 | {
17 | "src": "assets/icon/avatar.svg",
18 | "type": "image/svg",
19 | "sizes": "512x512",
20 | "purpose": "maskable"
21 | }
22 | ],
23 | "start_url": ".",
24 | "display": "standalone",
25 | "theme_color": "#ffffff",
26 | "background_color": "#ffffff"
27 | }
28 |
--------------------------------------------------------------------------------
/client/src/theme/index.ts:
--------------------------------------------------------------------------------
1 | /* Core CSS required for Ionic components to work properly */
2 | import '@ionic/react/css/core.css';
3 |
4 | /* Basic CSS for apps built with Ionic */
5 | import '@ionic/react/css/normalize.css';
6 | import '@ionic/react/css/structure.css';
7 | import '@ionic/react/css/typography.css';
8 |
9 | /* Optional CSS utils that can be commented out */
10 | import '@ionic/react/css/padding.css';
11 | import '@ionic/react/css/float-elements.css';
12 | import '@ionic/react/css/text-alignment.css';
13 | import '@ionic/react/css/text-transformation.css';
14 | import '@ionic/react/css/flex-utils.css';
15 | import '@ionic/react/css/display.css';
16 |
17 | /* Theme variables */
18 | import './variables.css';
19 |
20 | import './styles.css';
21 |
--------------------------------------------------------------------------------
/server/model/task.graphql:
--------------------------------------------------------------------------------
1 | """
2 | @model
3 | @datasync
4 | """
5 | type Task {
6 | """@id"""
7 | id: ObjectID!
8 | title: String!
9 | description: String!
10 | status: TaskStatus
11 | type: String
12 | priority: Int
13 | public: Boolean
14 | startDate: DateTime
15 | payload: JSON
16 |
17 | """
18 | @oneToMany(field: 'note')
19 | """
20 | comments: [Comment]!
21 | }
22 |
23 | """
24 | @model
25 | @crud(delete: false)
26 | @crud(update: false)
27 | """
28 | type Comment {
29 | """@id"""
30 | id: ObjectID!
31 | message: String!
32 | author: String!
33 | }
34 |
35 | enum TaskStatus {
36 | OPEN
37 | ASSIGNED
38 | COMPLETE
39 | }
40 |
41 | ### Custom types used by model
42 |
43 | scalar DateTime
44 | scalar JSON
45 | scalar ObjectID
46 |
--------------------------------------------------------------------------------
/server/src/db.ts:
--------------------------------------------------------------------------------
1 | import { Config } from './config/config';
2 | import e = require('express');
3 |
4 | const MongoClient = require('mongodb').MongoClient;
5 |
6 | export async function connect(config: Config) {
7 | // TODO config
8 | let url: string;
9 |
10 | if (config.db.user && config.db.password) {
11 | url = `mongodb://${config.db.user}:${config.db.password}@${config.db.host}:${config.db.port}/${config.db.database}`;
12 | } else {
13 | url = `mongodb://${config.db.host}:${config.db.port}/${config.db.database}`
14 | }
15 |
16 | // Use connect method to connect to the server
17 | const client = await MongoClient.connect(url, { useUnifiedTopology: true });
18 | const db = client.db(config.db.database);
19 | return db;
20 | }
21 |
22 |
--------------------------------------------------------------------------------
/client/src/forms/TaskForm.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { AutoForm, AutoFields, ErrorsField, SubmitField } from "uniforms-ionic";
3 | import { taskEditSchema, taskViewSchema } from "./task";
4 |
5 | export function TaskForm(props: { model: any, handleSubmit: any }) {
6 | // Workaround for missing types for submit
7 | const Submit = SubmitField as any;
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
17 | export function TaskView(props: { model: any }) {
18 | return (
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/.graphqlrc.yml:
--------------------------------------------------------------------------------
1 | ## GraphQL Config Generated by Graphback
2 | ## Configuration is being used to generate client side queries
3 | schema: ./server/src/schema/schema.graphql
4 | documents: ./client/src/graphql/**/*.graphql
5 | extensions:
6 | graphback:
7 | model: ./server/model/task.graphql
8 | crud:
9 | create: true
10 | update: true
11 | findAll: true
12 | find: true
13 | delete: true
14 | subCreate: true
15 | subUpdate: true
16 | subDelete: true
17 | plugins:
18 | ## Schema for preview only - server is not using it as it is generated at runtime
19 | graphback-schema:
20 | format: graphql
21 | outputPath: ./server/src/schema
22 | ## Client side queries
23 | graphback-client:
24 | format: 'ts'
25 | outputFile: ./client/src/graphql/generated.ts
--------------------------------------------------------------------------------
/scripts/publish_container.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | [ -z "$CI" ] && echo "This script is meant to run only from CircleCI." && exit 1;
4 | [ -z "$DOCKERHUB_USERNAME" ] && echo "Undefined DOCKERHUB_USERNAME, skipping publish" && exit 1;
5 | [ -z "$DOCKERHUB_PASSWORD" ] && echo "Undefined DOCKERHUB_PASSWORD, skipping publish" && exit 1;
6 |
7 | docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_PASSWORD
8 |
9 | #use TAG env. variable to create the container with the given tag
10 | TAG="${TAG:-latest}"
11 |
12 | NAMESPACE="aerogear"
13 | CONTAINER="$NAMESPACE/datasync-starter:$TAG"
14 | CONTAINER_LATEST="$NAMESPACE/datasync-starter:latest"
15 |
16 | echo "Building docker container $CONTAINER"
17 | docker build -f Dockerfile -t "$CONTAINER" . && docker push "$CONTAINER" && \
18 | docker tag "$CONTAINER" "$CONTAINER_LATEST" && docker push "$CONTAINER_LATEST"
19 |
--------------------------------------------------------------------------------
/server/src/pubsub.ts:
--------------------------------------------------------------------------------
1 | import mqtt from 'mqtt'
2 | import { PubSub } from 'apollo-server-express'
3 | import { MQTTPubSub } from '@aerogear/graphql-mqtt-subscriptions'
4 | import { config } from "./config/config"
5 |
6 | export function connectToPubSub() {
7 | if (config.mqttConfig) {
8 | const mqttOptions = config.mqttConfig;
9 | // Types are broken
10 | const client = mqtt.connect(mqttOptions.mqttHost, mqttOptions as any)
11 |
12 | console.log(`attempting to connect to messaging service ${mqttOptions.mqttHost}`)
13 |
14 | client.on('connect', () => {
15 | console.log('connected to messaging service')
16 | })
17 |
18 | client.on('error', (error) => {
19 | console.log('error with mqtt connection')
20 | console.log(error)
21 | })
22 |
23 | return new MQTTPubSub({ client })
24 | }
25 | console.log('Using In Memory PubSub')
26 | return new PubSub()
27 | }
--------------------------------------------------------------------------------
/client/src/helpers/ConflictLogger.ts:
--------------------------------------------------------------------------------
1 | import { ConflictListener } from 'offix-client';
2 |
3 | export class ConflictLogger implements ConflictListener {
4 | conflictOccurred(operationName:any, resolvedData:any, server:any, client:any) {
5 | console.log("Conflict occurred with the following:")
6 | console.log(`
7 | data: ${JSON.stringify(resolvedData)},
8 | server: ${JSON.stringify(server)},
9 | client: ${JSON.stringify(client)},
10 | operation: ${JSON.stringify(operationName)}
11 | `);
12 | }
13 | mergeOccurred(operationName:any, resolvedData:any, server:any, client:any) {
14 | console.log("Merge occurred with the following:")
15 | console.log(`
16 | data: ${JSON.stringify(resolvedData)},
17 | server: ${JSON.stringify(server)},
18 | client: ${JSON.stringify(client)},
19 | operation: ${JSON.stringify(operationName)}
20 | `);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/client/src/components/OfflineQueueBadge.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { IonLabel, IonButton, IonBadge } from '@ionic/react';
3 | import { useApolloOfflineClient } from 'react-offix-hooks';
4 | import { Link } from 'react-router-dom';
5 |
6 | export const OfflineQueueBadge: React.FC = () => {
7 |
8 | const client = useApolloOfflineClient();
9 | const [queue, setQueue] = useState(0);
10 |
11 | // eslint-disable-next-line
12 | useEffect(() => {
13 | setQueue(client.queue.entries.length);
14 | });
15 |
16 | return (
17 |
18 |
19 |
20 | Offline changes
21 |
22 |
23 |
24 | { queue }
25 |
26 |
27 | );
28 |
29 | }
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "dist",
4 | "declarationDir": "types",
5 | "lib": [
6 | "esnext",
7 | "esnext.asynciterable",
8 | "es2015",
9 | "es2018.promise",
10 | "dom",
11 | "es2016"
12 | ],
13 | "resolveJsonModule": true,
14 | "noImplicitAny": false,
15 | "preserveConstEnums": true,
16 | "strict": false,
17 | "strictNullChecks": true,
18 | "esModuleInterop": true,
19 | "target": "es6",
20 | "module": "commonjs",
21 | "moduleResolution": "node",
22 | "allowSyntheticDefaultImports": true,
23 | "importHelpers": true,
24 | "alwaysStrict": false,
25 | "sourceMap": true,
26 | "declaration": true,
27 | "noImplicitReturns": true,
28 | "noUnusedLocals": false,
29 | "noUnusedParameters": false,
30 | "noImplicitThis": false
31 | },
32 | "exclude": [
33 | "node_modules",
34 | "dist",
35 | "types"
36 | ]
37 | }
--------------------------------------------------------------------------------
/client/src/helpers/mutationOptions.ts:
--------------------------------------------------------------------------------
1 | import { getUpdateFunction, CacheOperation } from 'offix-cache';
2 | import { findTasks } from '../graphql/generated';
3 |
4 | export const createTask = {
5 | updateQuery: findTasks,
6 | returnType: 'Task',
7 | mutationName: 'createTask',
8 | operationType: CacheOperation.ADD,
9 | returnField: 'items'
10 | };
11 |
12 | export const updateTask = {
13 | updateQuery: findTasks,
14 | returnType: 'Task',
15 | mutationName: 'updateTask',
16 | operationType: CacheOperation.REFRESH,
17 | returnField: 'items'
18 | };
19 |
20 | export const deleteTask = {
21 | updateQuery: findTasks,
22 | returnType: 'Task',
23 | mutationName: 'deleteTask',
24 | operationType: CacheOperation.DELETE,
25 | returnField: 'items'
26 | };
27 |
28 | export const globalCacheUpdates = {
29 | createTask: getUpdateFunction(createTask),
30 | updateTask: getUpdateFunction(updateTask),
31 | deleteTask: getUpdateFunction(deleteTask),
32 | }
33 |
--------------------------------------------------------------------------------
/client/README.adoc:
--------------------------------------------------------------------------------
1 | = React Client
2 |
3 | Example React Web application using AeroGear DataSync GraphQL capabilities
4 |
5 | == Getting Started
6 |
7 | Requirements:
8 |
9 | - Node.js 12.x or above to run server
10 | - (optional) Keycloack server
11 |
12 | === Running the Client
13 |
14 | . Install Ionic
15 | +
16 | ```shell
17 | npm install -g @ionic/cli
18 | ```
19 |
20 | . Install dependencies
21 | +
22 | ```shell
23 | npm install
24 | ```
25 |
26 | . Start the app
27 | +
28 | ```shell
29 | npm run start
30 | ```
31 |
32 | === Adding keycloak integration to the client
33 |
34 | Rename the `keycloak.example.json` to `keycloak.json` and update the fields
35 | accordingly.
36 |
37 | [source,js]
38 | ----
39 | {
40 | "realm": "",
41 | "auth-server-url": "https://your-server/auth",
42 | "ssl-required": "none",
43 | "resource": "",
44 | "public-client": true,
45 | "use-resource-role-mappings": true,
46 | "confidential-port": 0
47 | }
48 | ----
49 |
50 |
--------------------------------------------------------------------------------
/client/src/helpers/subscriptionOptions.ts:
--------------------------------------------------------------------------------
1 | import { createSubscriptionOptions, CacheOperation } from 'offix-cache';
2 | import { newTask, updatedTask, deletedTask, findTasks, getTask, newComment } from '../graphql/generated';
3 |
4 | export const add = createSubscriptionOptions({
5 | subscriptionQuery: newTask,
6 | cacheUpdateQuery: findTasks,
7 | operationType: CacheOperation.ADD,
8 | returnField: 'items'
9 | });
10 |
11 | export const edit = createSubscriptionOptions({
12 | subscriptionQuery: updatedTask,
13 | cacheUpdateQuery: findTasks,
14 | operationType: CacheOperation.REFRESH,
15 | returnField: 'items'
16 | });
17 |
18 | export const remove = createSubscriptionOptions({
19 | subscriptionQuery: deletedTask,
20 | cacheUpdateQuery: findTasks,
21 | operationType: CacheOperation.DELETE,
22 | returnField: 'items'
23 | });
24 |
25 | export const addComment = createSubscriptionOptions({
26 | subscriptionQuery: newComment,
27 | cacheUpdateQuery: getTask,
28 | operationType: CacheOperation.ADD,
29 | returnField: 'comments'
30 | });
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "datasync-starter",
3 | "version": "1.0.0",
4 | "private": true,
5 | "description": "Mono repository for DataSync Starter",
6 | "main": "index.js",
7 | "devDependencies": {
8 | "del-cli": "3.0.1",
9 | "graphql": "15.3.0",
10 | "graphback-cli": "0.14.0"
11 | },
12 | "scripts": {
13 | "start:server": "cd server && yarn start",
14 | "start:client": "cd client && yarn start",
15 | "build:server": "cd server && yarn build",
16 | "build:client": "cd client && yarn build",
17 | "build:clientGeneric": "cd client/ && yarn build:generic",
18 | "prepare:client": "del ./client/build ./server/website ; yarn build:clientGeneric && mv ./client/build/ ./server/website",
19 | "build": "yarn workspaces run build",
20 | "unlock": "yarn workspaces run del package-lock.json && del yarn.lock",
21 | "clean": "yarn workspaces run del ./dist && del ./types",
22 | "walkthrough": "./scripts/create_walkthrough.sh"
23 | },
24 | "workspaces": [
25 | "client",
26 | "server"
27 | ],
28 | "resolutions": {
29 | "@types/react": "16.9.36"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/client/public/assets/shapes.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/src/components/Router.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | HashRouter as AppRouter,
4 | Switch,
5 | Redirect,
6 | Route,
7 | } from 'react-router-dom';
8 | import { IonApp } from '@ionic/react';
9 | import { TaskPage, AddTaskPage, OfflineQueuePage, UpdateTaskPage, ProfilePage } from '../pages';
10 | import { ViewTaskPage } from '../pages/ViewTaskPage';
11 |
12 | export const Router: React.FC = () => {
13 | return (
14 |
15 |
16 |
17 |
22 |
23 |
24 |
25 |
26 |
27 | } />
28 |
29 |
30 |
31 | );
32 | }
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Router } from './components';
3 | import { useApolloOfflineClient } from "react-offix-hooks";
4 |
5 | // load all styles
6 | import './theme';
7 | import { ConflictListener } from 'offix-client';
8 | import { IonToast } from '@ionic/react';
9 |
10 | export const App: React.FC = () => {
11 |
12 | const client = useApolloOfflineClient();
13 | const [showConflict, setShowConflict] = useState(false);
14 |
15 | useEffect(() => {
16 | const conflictListener: ConflictListener = {
17 | mergeOccurred() {
18 | console.log("Merge occured! ")
19 | },
20 | conflictOccurred() {
21 | setShowConflict(true);
22 | }
23 | };
24 | client.addConflictListener(conflictListener);
25 |
26 | return function cleanup() {
27 | client.removeConflictListener(conflictListener);
28 | }
29 | }, [client]);
30 |
31 | return (
32 | <>
33 |
34 | setShowConflict(false)}
37 | message="Conflict Occurred 👌👌👌"
38 | duration={2000}
39 | />
40 | >
41 | );
42 | };
43 |
44 |
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | AeroGear DataSync Starter
6 |
7 |
8 |
9 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/server/src/index.ts:
--------------------------------------------------------------------------------
1 | import dotenv from 'dotenv';
2 | // Setup env variables
3 | dotenv.config()
4 |
5 | import cors from 'cors';
6 | import express from 'express';
7 | import http from 'http'
8 | import { config } from './config/config'
9 | import { createApolloServer } from './graphql';
10 |
11 | async function start() {
12 | const app = express();
13 |
14 | app.use(cors());
15 | app.use('/', express.static('website'))
16 | app.get('/health', (req, res) => res.sendStatus(200));
17 |
18 | const apolloServer = await createApolloServer(app, config);
19 | const httpServer = http.createServer(app)
20 | apolloServer.installSubscriptionHandlers(httpServer)
21 |
22 | httpServer.listen(config.port, () => {
23 | console.log(`\n ***********************************************************
24 | 🎮 Ionic PWA application available at http://localhost:${config.port}
25 | 🚀 GraphQL Playground is available at http://localhost:${config.port}/graphql
26 | ***********************************************************`)
27 | })
28 | }
29 |
30 | start().catch((err) => {
31 | console.error(err);
32 | process.exit(1);
33 | })
34 |
35 |
36 | process.on('unhandledRejection', (error: any) => {
37 | console.error(error.message, error.stack)
38 | process.exit(1)
39 | })
40 |
--------------------------------------------------------------------------------
/client/src/helpers/CapacitorNetworkStatus.ts:
--------------------------------------------------------------------------------
1 | import { NetworkStatus, NetworkStatusChangeCallback } from "offix-offline";
2 | import { Plugins } from '@capacitor/core';
3 | const { Network } = Plugins;
4 |
5 | /**
6 | * Web networks status implementation based on: Mozilla
7 | * See: https://developer.mozilla.org/en-US/docs/Web/API/NavigatorOnLine/onLine
8 | */
9 | export class CapacitorNetworkStatus implements NetworkStatus {
10 |
11 | listeners: NetworkStatusChangeCallback[] = [];
12 |
13 | constructor() {
14 | Network.addListener("networkStatusChange", this.handleNetworkStatusChange.bind(this));
15 | }
16 |
17 | public addListener(listener: NetworkStatusChangeCallback): void {
18 | this.listeners.push(listener);
19 | }
20 |
21 | public removeListener(listener: NetworkStatusChangeCallback): void {
22 | const index = this.listeners.indexOf(listener);
23 | if (index >= 0) {
24 | this.listeners.splice(index, 1);
25 | }
26 | }
27 |
28 | public isOffline(): Promise {
29 | return new Promise(async (resolve) => {
30 | let status = await Network.getStatus();
31 | // @ts-ignore
32 | resolve(!status.connected);
33 | });
34 | }
35 |
36 | private handleNetworkStatusChange(status: any) {
37 | const online = status.connected;
38 | this.listeners.forEach((listener) => {
39 | listener({ online });
40 | });
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/client/src/declarations.ts:
--------------------------------------------------------------------------------
1 | import { KeycloakInstance } from "keycloak-js";
2 | import { ApolloOfflineClient } from "offix-client";
3 |
4 | export interface ITask {
5 | id: string;
6 | version: number;
7 | title: string;
8 | description: string;
9 | status: TaskStatus;
10 | __typename?: string;
11 | };
12 |
13 | export enum TaskStatus {
14 | OPEN = 'OPEN',
15 | ASSIGNED = 'ASSIGNED',
16 | COMPLETE = 'COMPLETE'
17 | };
18 |
19 | export interface AllTasks {
20 | allTasks: ITask[];
21 | task: ITask;
22 | taskAdded: ITask;
23 | taskDeleted: ITask;
24 | taskUpdated: ITask;
25 | };
26 |
27 | export enum MutationType {
28 | CREATED = 'CREATED',
29 | MUTATED = 'MUTATED',
30 | DELETED = 'DELETED',
31 | };
32 |
33 | export interface IOfflineStore {
34 | offlineStore: [ITask]
35 | }
36 |
37 | export interface ITaskListProps {
38 | tasks: [ITask]
39 | }
40 |
41 | export interface IOfflineListProps {
42 | offlineStore: Array
43 | };
44 |
45 | export interface ITaskProps {
46 | task: ITask,
47 | updateTask: Function,
48 | deleteTask: Function
49 | };
50 |
51 | export interface IContainerProps {
52 | app: React.FC
53 | };
54 |
55 | export interface ILoadingProps {
56 | loading: boolean
57 | };
58 |
59 | export interface IAuthHeaders {
60 | headers: {
61 | Authorization: String
62 | }
63 | }
64 |
65 | export interface ILogoutParams {
66 | keycloak: Keycloak.KeycloakInstance | undefined,
67 | client: ApolloOfflineClient
68 | }
--------------------------------------------------------------------------------
/client/src/AppContainer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { ApolloOfflineClient } from 'offix-client';
3 | import { ApolloOfflineProvider } from 'react-offix-hooks';
4 | import { ApolloProvider } from '@apollo/react-hooks';
5 | import { AuthContext } from './AuthContext';
6 | import { clientConfig } from './config';
7 | import { Loading } from './components/Loading';
8 | import { IContainerProps } from './declarations';
9 | import { getKeycloakInstance } from './auth/keycloakAuth';
10 |
11 | let keycloak: any;
12 | const apolloClient = new ApolloOfflineClient(clientConfig);
13 |
14 | export const AppContainer: React.FC = ({ app: App }) => {
15 |
16 | const [initialized, setInitialized] = useState(false);
17 |
18 | // Initialize the client
19 | useEffect(() => {
20 | const init = async () => {
21 | keycloak = await getKeycloakInstance();
22 | await apolloClient.init();
23 | if (keycloak) {
24 | await keycloak?.loadUserProfile();
25 | }
26 | setInitialized(true);
27 | }
28 | init();
29 | }, []);
30 |
31 | if (!initialized) return ;
32 |
33 | // return container with keycloak provider
34 | return (
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | );
43 |
44 |
45 | };
46 |
--------------------------------------------------------------------------------
/client/src/components/OfflineList.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IonItem, IonLabel, IonItemGroup, IonBadge, IonList, IonContent } from '@ionic/react';
3 | import { Empty } from './Empty';
4 |
5 | export const OfflineList: React.FC = ({ offlineStore }) => {
6 |
7 | if (!offlineStore) return Loading...
;
8 |
9 | if (offlineStore.length === 0) {
10 | const message = (You currently have no changes
staged offline.
);
11 | return (
12 |
13 | )
14 | };
15 |
16 | return (
17 |
18 |
19 | {
20 | offlineStore.map(({ operation }: any, index: any) => {
21 | const { context, variables } = operation.op;
22 | const keys = Object.keys(variables.input);
23 | return (
24 |
25 |
26 |
27 |
28 | Mutation type:
29 |
30 | {context.operationName}
31 |
32 |
33 |
34 | { keys.map((key, i) => - { variables.input[key] }
) }
35 |
36 |
37 |
38 |
39 | );
40 | })
41 | }
42 |
43 |
44 | );
45 |
46 |
47 | }
--------------------------------------------------------------------------------
/.openshift/amq-topics.yml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Template
3 | labels:
4 | template: amq-online-topic-creator
5 | metadata:
6 | name: amq-online-topic-creator
7 | objects:
8 | - apiVersion: enmasse.io/v1beta1
9 | kind: AddressSpace
10 | metadata:
11 | name: datasync
12 | spec:
13 | type: brokered
14 | plan: brokered-single-broker
15 | - apiVersion: enmasse.io/v1beta1
16 | kind: Address
17 | metadata:
18 | name: datasync.${AMQ_ADDRESS}
19 | spec:
20 | address: ${AMQ_ADDRESS}
21 | type: topic
22 | plan: brokered-topic
23 | - apiVersion: user.enmasse.io/v1beta1
24 | kind: MessagingUser
25 | metadata:
26 | name: datasync.${AMQ_USERNAME}
27 | spec:
28 | username: ${AMQ_USERNAME}
29 | authentication:
30 | type: password
31 | password: ${AMQ_USER_PASSWORD}
32 | authorization:
33 | - addresses: ["*"]
34 | operations: ["send", "recv"]
35 | parameters:
36 | - description: Messaging user created in AMQ Online. The showcase server will authenticate with AMQ as this user.
37 | displayName: AMQ Messaging User Name
38 | name: AMQ_USERNAME
39 | value: messaging-user
40 | - description: Create your own password with `$ echo | base64` - the default password is Password1
41 | displayName: AMQ Messaging User Password
42 | name: AMQ_USER_PASSWORD
43 | value: UGFzc3dvcmQx # (base64 encoded) Password1
44 | - description: Address
45 | displayName: AMQ Messaging Address
46 | name: AMQ_ADDRESS
47 | value: graphql
48 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "datasync-server-starter",
3 | "version": "1.0.0",
4 | "private": true,
5 | "scripts": {
6 | "start": "ts-node src/index.ts",
7 | "startMQTT": "MQTT_HOST=127.0.0.1:1883 ts-node src/index.ts",
8 | "build": "tsc",
9 | "keycloak": "docker-compose -f ./integrations/keycloak/docker-compose.yml up",
10 | "keycloak:init": "node ./integrations/keycloak/initKeycloak.js",
11 | "mqtt": "docker-compose -f ./integrations/mqtt/docker-compose.yml up",
12 | "amq:config": "node ./integrations/mqtt/configureAMQ.js"
13 | },
14 | "license": "MIT",
15 | "devDependencies": {
16 | "@types/cors": "2.8.6",
17 | "@types/express": "4.17.6",
18 | "@types/node": "13.13.12",
19 | "keycloak-request-token": "0.1.0",
20 | "ts-node": "9.0.0",
21 | "ts-node-dev": "1.0.0-pre.44",
22 | "tslint": "6.1.2",
23 | "typescript": "4.0.2"
24 | },
25 | "dependencies": {
26 | "graphql": "15.3.0",
27 | "@aerogear/graphql-mqtt-subscriptions": "1.1.3",
28 | "graphql-subscriptions": "1.1.0",
29 | "graphback": "0.14.0",
30 | "@graphback/keycloak-authz": "0.14.0",
31 | "@graphback/datasync": "0.14.0",
32 | "@graphback/runtime-mongo": "0.14.0",
33 | "@graphql-tools/graphql-file-loader": "6.0.15",
34 | "@graphql-tools/load": "6.0.15",
35 | "@types/react": "16.9.36",
36 | "apollo-server-express": "2.17.0",
37 | "cors": "2.8.5",
38 | "dotenv": "8.2.0",
39 | "express": "4.17.1",
40 | "express-session": "1.17.1",
41 | "graphql-scalars": "1.1.3",
42 | "graphql-tag": "2.11.0",
43 | "keycloak-connect": "11.0.2",
44 | "keycloak-connect-graphql": "0.6.0",
45 | "mongodb": "3.5.9"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | working_directory: ~/aerogear
5 | docker:
6 | - image: circleci/node:lts
7 | steps:
8 | - checkout
9 | - restore_cache:
10 | key: dependency-cache-{{ checksum "client/package.json" }}-{{checksum "server/package.json"}}
11 | - run:
12 | name: install-dependencies
13 | command: yarn
14 | - run:
15 | name: install-package-dependencies
16 | command: yarn
17 | - save_cache:
18 | key: dependency-cache-{{ checksum "client/package.json" }}-{{checksum "server/package.json"}}
19 | paths:
20 | - ./node_modules
21 | - run:
22 | name: run build
23 | command: "yarn build"
24 |
25 | publish_container:
26 | docker:
27 | # image for building docker containers
28 | - image: circleci/node:lts
29 | steps:
30 | - checkout
31 | - run:
32 | name: install-dependencies
33 | command: yarn
34 | # special workaround to allow running docker in docker https://circleci.com/docs/2.0/building-docker-images/
35 | - setup_remote_docker:
36 | version: 17.05.0-ce
37 | - run: |
38 | yarn
39 | yarn prepare:client
40 | - run: |
41 | cd server
42 | TAG=$CIRCLE_TAG ../scripts/publish_container.sh
43 | workflows:
44 | version: 2
45 | build_and_release:
46 | jobs:
47 | - build:
48 | filters:
49 | tags:
50 | only: /.*/
51 | - publish_container:
52 | filters:
53 | tags:
54 | only: /.*/
55 | branches:
56 | only: ignored
57 |
58 |
59 |
--------------------------------------------------------------------------------
/client/src/pages/AddTaskPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { RouteComponentProps } from 'react-router-dom'
3 | import { IonContent, IonToast, IonCard } from '@ionic/react';
4 | import { useOfflineMutation } from 'react-offix-hooks';
5 | import { Header } from '../components/Header';
6 | import { createTask } from '../graphql/generated';
7 | import { TaskForm } from '../forms/TaskForm';
8 | import { mutationOptions } from '../helpers';
9 |
10 |
11 | export const AddTaskPage: React.FC = ({ history, match }) => {
12 |
13 | const [showToast, setShowToast] = useState(false);
14 | const [errorMessage, setErrorMessage] = useState('');
15 |
16 | const [createTaskMutation] = useOfflineMutation(createTask, mutationOptions.createTask);
17 |
18 | const handleError = (error: any) => {
19 | if (error.offline) {
20 | error.watchOfflineChange();
21 | history.push('/');
22 | return;
23 | }
24 | setErrorMessage(error.message);
25 | setShowToast(true);
26 | };
27 |
28 | const submit = (model: any) => {
29 | createTaskMutation({
30 | variables: { input: { ...model } }
31 | })
32 | .then(() => history.push('/'))
33 | .catch(handleError);
34 | };
35 |
36 | return (
37 | <>
38 |
39 |
40 |
41 |
42 |
43 | setShowToast(false)}
46 | message={errorMessage}
47 | position="top"
48 | color="danger"
49 | duration={2000}
50 | />
51 |
52 | >
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/client/src/theme/styles.css:
--------------------------------------------------------------------------------
1 | .list-md {
2 | padding: 0;
3 | }
4 |
5 | .task-item {
6 | padding-bottom: 0;
7 | }
8 |
9 | .item ion-label {
10 | overflow: visible;
11 | white-space: pre-wrap;
12 | }
13 |
14 | ion-note {
15 | padding: 10px;
16 | line-height: 21px;
17 | white-space: pre-wrap;
18 | }
19 |
20 | ion-badge {
21 | vertical-align: middle;
22 | white-space: pre-line;
23 | text-align: left;
24 | }
25 |
26 | ion-footer div {
27 | margin: 10px;
28 | }
29 |
30 | .create-button {
31 | margin-left:15px;
32 | z-index: 9999;
33 | }
34 |
35 | .trash-button {
36 | margin-left:5px;
37 | z-index: 9999;
38 | }
39 |
40 | .network-badge {
41 | float: right;
42 | height: calc(2.1em + 4px);
43 | font-size: 13px;
44 | text-transform: uppercase;
45 | vertical-align: middle;
46 | line-height: calc(2.1em + 4px);
47 | --padding-top: 0;
48 | --padding-bottom: 0;
49 | --padding-start: 0.9em;
50 | --padding-end: 0.9em;
51 | }
52 |
53 | .offline-queue-button {
54 | -webkit-margin-start: 0px;
55 | margin-inline-start: 0px;
56 | }
57 |
58 | .offline-queue-badge {
59 | margin-left: -7px;
60 | vertical-align: top;
61 | }
62 |
63 | .task-item {
64 | padding-bottom: 0;
65 | }
66 |
67 | .list-md {
68 | padding: 0;
69 | }
70 |
71 | .item .sc-ion-label-md-h {
72 | overflow: visible;
73 | white-space: pre-wrap;
74 | }
75 |
76 | ion-note {
77 | padding: 10px;
78 | line-height: 21px;
79 | white-space: pre-wrap;
80 | }
81 |
82 | ion-badge {
83 | vertical-align: middle;
84 | white-space: pre-line;
85 | text-align: left;
86 | }
87 |
88 | .queue-empty {
89 | height: 100%;
90 | display: flex;
91 | align-items: center;
92 | text-align: center;
93 | }
94 |
95 | .queue-empty p {
96 | line-height: 150%
97 | }
98 |
99 | .submit-btn {
100 | margin-top: 30px;
101 | }
--------------------------------------------------------------------------------
/server/src/graphql.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path';
2 | import { connect } from './db';
3 | import { Config } from './config/config';
4 | import { ApolloServer, ApolloServerExpressConfig } from "apollo-server-express";
5 | import { Express } from "express";
6 | import scalars from './resolvers/scalars';
7 | import customResolvers from './resolvers/custom';
8 | import { buildKeycloakApolloConfig } from './auth';
9 | import { createCRUDService } from './crudServiceCreator'
10 | import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'
11 | import { loadSchemaSync } from '@graphql-tools/load'
12 | import { buildGraphbackAPI } from "graphback"
13 | import { DataSyncPlugin, createDataSyncMongoDbProvider } from "@graphback/datasync"
14 |
15 | /**
16 | * Creates Apollo server
17 | */
18 | export const createApolloServer = async function (app: Express, config: Config) {
19 | const db = await connect(config);
20 |
21 | const modelDefs = loadSchemaSync(resolve(__dirname, '../model/task.graphql'), {
22 | loaders: [
23 | new GraphQLFileLoader()
24 | ]
25 | })
26 |
27 | const { typeDefs, resolvers, contextCreator } = buildGraphbackAPI(modelDefs, {
28 | serviceCreator: createCRUDService(),
29 | dataProviderCreator: createDataSyncMongoDbProvider(db),
30 | plugins: [
31 | new DataSyncPlugin()
32 | ]
33 | });
34 |
35 | let apolloConfig: ApolloServerExpressConfig = {
36 | typeDefs: typeDefs,
37 | resolvers: Object.assign(resolvers, customResolvers, scalars),
38 | playground: true,
39 | context: contextCreator
40 | }
41 |
42 | if (config.keycloakConfig) {
43 | apolloConfig = buildKeycloakApolloConfig(app, apolloConfig)
44 | }
45 |
46 | apolloConfig.resolvers = { ...apolloConfig.resolvers, ...scalars, ...customResolvers };
47 |
48 | const apolloServer = new ApolloServer(apolloConfig)
49 | apolloServer.applyMiddleware({ app });
50 |
51 | return apolloServer;
52 | }
53 |
--------------------------------------------------------------------------------
/server/src/config/config.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 |
4 | export class Config {
5 | public port: string | number
6 | public db: { database: string; user?: string; password?: string; host: string, port: number | string }
7 | public keycloakConfigPath: string
8 | public keycloakConfig: any
9 | public playgroundConfig: { tabs: { endpoint: string; variables: {}; query: string }[] }
10 | public mqttConfig: any;
11 |
12 | constructor() {
13 | this.port = process.env.PORT || 4000
14 |
15 | this.db = {
16 | database: process.env.MONGO_COLLECTION || 'showcase',
17 | host: process.env.MONGO_HOST || '127.0.0.1',
18 | user: process.env.MONGO_USER,
19 | password: process.env.MONGO_PASSWORD,
20 | port: process.env.MONGO_PORT || 27017
21 | }
22 |
23 | const mqttHost = process.env.MQTT_HOST
24 |
25 | if (mqttHost) {
26 | console.log('Using MQTT PubSub')
27 | this.mqttConfig = {
28 | host: mqttHost,
29 | servername: mqttHost, // needed to work in OpenShift. Lookup SNI.
30 | username: process.env.MQTT_USERNAME || '',
31 | password: process.env.MQTT_PASSWORD || '',
32 | port: process.env.MQTT_PORT || '1883',
33 | protocol: process.env.MQTT_PROTOCOL || 'mqtt',
34 | rejectUnauthorized: false
35 | }
36 | }
37 |
38 | this.keycloakConfigPath = process.env.KEYCLOAK_CONFIG || path.resolve(__dirname, './keycloak.json')
39 | this.keycloakConfig = readConfig(this.keycloakConfigPath)
40 |
41 | this.playgroundConfig = {
42 | tabs: [
43 | {
44 | endpoint: `/graphql`,
45 | variables: {},
46 | query: fs.readFileSync(path.resolve(__dirname, './playground.gql'), 'utf8')
47 | }
48 | ]
49 | }
50 | }
51 | }
52 |
53 | function readConfig(path) {
54 | try {
55 | return JSON.parse(fs.readFileSync(path, 'utf8'))
56 | } catch (e) {
57 | console.error(`Warning: couldn't find keycloak config at ${path}`)
58 | }
59 | }
60 |
61 | export const config = new Config()
--------------------------------------------------------------------------------
/client/src/components/TaskList.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Task } from './Task';
3 | import { IonList, IonToast } from '@ionic/react';
4 | import { useOfflineMutation } from 'react-offix-hooks';
5 | import { ITask } from '../declarations';
6 | import { Empty } from './Empty';
7 | import { mutationOptions } from '../helpers';
8 | import { updateTask, deleteTask } from '../graphql/generated';
9 |
10 | export const TaskList: React.FC = ({ tasks }) => {
11 |
12 | const [updateTaskMutation] = useOfflineMutation(updateTask, mutationOptions.updateTask);
13 | const [deleteTaskMutation] = useOfflineMutation(deleteTask, mutationOptions.deleteTask);
14 |
15 | const [showToast, setShowToast] = useState(false);
16 | const [errorMessage, setErrorMessage] = useState('');
17 |
18 | const handleError = (error: any) => {
19 | if (error.offline) {
20 | error.watchOfflineChange();
21 | }
22 | if (error.graphQLErrors) {
23 | console.log(error.graphQLErrors);
24 | setErrorMessage(error.message);
25 | setShowToast(true);
26 | }
27 | }
28 |
29 | const handleDelete = (task: ITask) => {
30 | const { comments, __typename, createdAt, ...input } = task as any;
31 | deleteTaskMutation({
32 | variables: { input }
33 | }).catch(handleError);
34 | };
35 |
36 | const handleUpdate = (task: ITask) => {
37 | const { comments, __typename, ...input } = task as any;
38 | updateTaskMutation({
39 | variables: { input }
40 | })
41 | .catch(handleError);
42 | }
43 |
44 | if (tasks.length < 1) {
45 | const message = (You currently have no tasks.
);
46 | return
47 | };
48 |
49 | return (
50 | <>
51 |
52 | {
53 | tasks.map((task: ITask) => {
54 | return ;
55 | })
56 | }
57 |
58 | setShowToast(false)}
61 | message={errorMessage}
62 | position="top"
63 | color="danger"
64 | duration={2000}
65 | />
66 | >
67 | );
68 |
69 | };
70 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "datasync-starter-client",
3 | "version": "0.0.1",
4 | "private": true,
5 | "dependencies": {
6 | "@apollo/react-hooks": "3.1.5",
7 | "@capacitor/core": "2.4.1",
8 | "@ionic/react": "5.1.1",
9 | "@ionic/react-router": "5.1.1",
10 | "@testing-library/jest-dom": "5.9.0",
11 | "@testing-library/react": "11.0.4",
12 | "@testing-library/user-event": "12.1.5",
13 | "@types/jest": "26.0.13",
14 | "@types/node": "12.12.47",
15 | "@types/react": "16.9.36",
16 | "@types/react-dom": "16.9.8",
17 | "@types/react-router": "5.1.7",
18 | "@types/react-router-dom": "5.1.5",
19 | "apollo-link-context": "1.0.20",
20 | "apollo-link-ws": "1.0.20",
21 | "graphql": "15.3.0",
22 | "graphql-tag": "2.11.0",
23 | "ionicons": "5.0.1",
24 | "keycloak-js": "11.0.2",
25 | "offix-cache": "0.16.0-alpha2",
26 | "offix-client": "0.16.0-alpha2",
27 | "react": "16.13.1",
28 | "react-dom": "16.13.1",
29 | "react-offix-hooks": "0.16.0-alpha2",
30 | "react-router": "5.2.0",
31 | "react-router-dom": "5.2.0",
32 | "react-scripts": "3.4.1",
33 | "simpl-schema": "1.7.3",
34 | "subscriptions-transport-ws": "0.9.16",
35 | "typescript": "4.0.2",
36 | "uniforms": "3.0.0-alpha.4",
37 | "uniforms-bridge-simple-schema-2": "3.0.0-alpha.4",
38 | "uniforms-ionic": "0.1.0"
39 | },
40 | "scripts": {
41 | "start": "npm run build && cap serve",
42 | "dev": "react-scripts start",
43 | "build": "react-scripts build",
44 | "build:generic": "REACT_APP_URI_FORMAT=RELATIVEURI react-scripts build",
45 | "run:android": "cap copy android && cap open android",
46 | "run:ios": "cap copy ios && cap open ios",
47 | "eject": "react-scripts eject"
48 | },
49 | "eslintConfig": {
50 | "extends": "react-app"
51 | },
52 | "browserslist": {
53 | "production": [
54 | ">0.2%",
55 | "not dead",
56 | "not op_mini all"
57 | ],
58 | "development": [
59 | "last 1 chrome version",
60 | "last 1 firefox version",
61 | "last 1 safari version"
62 | ]
63 | },
64 | "devDependencies": {
65 | "@capacitor/cli": "2.4.1",
66 | "@types/simpl-schema": "0.2.7"
67 | },
68 | "description": "An Ionic project"
69 | }
70 |
--------------------------------------------------------------------------------
/.openshift/README.md:
--------------------------------------------------------------------------------
1 | ## OpenShift templates
2 |
3 | ### DataSync Starter App template
4 |
5 | Name: `datasync-app-template.yml`
6 |
7 | This template starts datasync container on top of the mongodb instances:
8 |
9 | #### Prerequisites
10 |
11 | 1. Running MongoDB instance
12 | 2. Connection details to MongoDB server
13 |
14 | #### Steps
15 |
16 | 1. Add template to your openshift
17 | 2. Provide MongoDB connection details
18 | 3. Wait for the pods to start
19 |
20 | # Deploying Server with AMQ
21 |
22 | Prerequisites
23 |
24 | * AMQ Online is installed in the cluster
25 |
26 |
27 | This section describes how to deploy the application in an OpenShift cluster by using the supplied `amq-topics.yml` template file.
28 | * The template is already prefilled with all of the necessary values that can be inspected
29 | * The only field you might want to change is `AMQ Messaging User Password`.
30 | * The default value is `Password1` in base64 encoding
31 | * The value *must* be base64 encoded
32 | * A custom value can be created in the terminal using `$ echo | base64`
33 | * Execute template on your openshift instance by `oc process -f amq-topics.yml | oc create -f -`
34 |
35 | The hostname for the AMQ Online Broker is only made available after the resources from the the template have been provisioned. One more step is needed to supply extra environment variables to running server.
36 |
37 | * From the terminal, ensure you have the correct namespace selected.
38 |
39 | ```
40 | oc project
41 | ```
42 |
43 | * Update the deployment to add the `MQTT_HOST` variable.
44 |
45 | ```
46 | oc get addressspace datasync -o jsonpath='{.status.endpointStatuses[?(@.name=="messaging")].serviceHost}'
47 | ```
48 |
49 | If you want to use service outside the OpenShift cluster please request external URL:
50 | ```
51 | oc get addressspace datasync -o jsonpath='{.status.endpointStatuses[?(@.name=="messaging")].externalHost}'
52 | ```
53 |
54 | Provide set of the environment variables required to connect to the running AMQ
55 |
56 | ```
57 | MQTT_HOST=messaging-nj2y0929dk-redhat-rhmi-amq-online.apps.youropenshift.io
58 | MQTT_PORT=443
59 | MQTT_PASSWORD=Password1
60 | MQTT_USERNAME=messaging-user
61 | MQTT_PROTOCOL=tls
62 | ```
63 |
64 | Check `../server/.env` file for all available variables
65 |
--------------------------------------------------------------------------------
/server/src/auth.ts:
--------------------------------------------------------------------------------
1 | import { Express } from "express";
2 | import { config } from './config/config';
3 |
4 | import {
5 | KeycloakTypeDefs,
6 | KeycloakSchemaDirectives,
7 | KeycloakSubscriptionContext,
8 | KeycloakSubscriptionHandler,
9 | KeycloakContext
10 | } from 'keycloak-connect-graphql'
11 |
12 | const session = require('express-session')
13 | const Keycloak = require('keycloak-connect')
14 |
15 | export function buildKeycloakApolloConfig(app: Express, apolloConfig: any) {
16 | const graphqlPath = `/graphql`;
17 | console.log("Using keycloak configuration")
18 |
19 | const memoryStore = new session.MemoryStore()
20 | app.use(session({
21 | secret: process.env.SESSION_SECRET_STRING || 'this should be a long secret',
22 | resave: false,
23 | saveUninitialized: true,
24 | store: memoryStore
25 | }))
26 |
27 | const keycloak = new Keycloak({
28 | store: memoryStore
29 | }, config.keycloakConfig);
30 | const keycloakSubscriptionHandler = new KeycloakSubscriptionHandler({ keycloak })
31 |
32 | app.use(keycloak.middleware())
33 |
34 | app.use(graphqlPath, keycloak.protect());
35 |
36 | return {
37 | typeDefs: [KeycloakTypeDefs, apolloConfig.typeDefs], // 1. Add the Keycloak Type Defs
38 | schemaDirectives: KeycloakSchemaDirectives,
39 | resolvers: apolloConfig.resolvers,
40 | playground: apolloConfig.playground,
41 | path: graphqlPath,
42 | context: (context) => {
43 | return {
44 | ...apolloConfig.context(context),
45 | kauth: new KeycloakContext({ req: context.req }) // 3. add the KeycloakContext to `kauth`
46 | }
47 | },
48 | subscriptions: {
49 | onConnect: async (connectionParams, websocket, connectionContext) => {
50 | const token = await keycloakSubscriptionHandler.onSubscriptionConnect(connectionParams)
51 | if (!token) {
52 | throw new Error("Cannot build keycloak token. Connection will be terminated")
53 | }
54 | return {
55 | ...apolloConfig.context,
56 | kauth: new KeycloakSubscriptionContext(token)
57 | }
58 | }
59 | },
60 | }
61 | }
62 |
63 |
64 |
--------------------------------------------------------------------------------
/devfile.yaml:
--------------------------------------------------------------------------------
1 | metadata:
2 | name: datasync-starter
3 | projects:
4 | - name: datasync-starter
5 | source:
6 | location: 'https://github.com/aerogear/datasync-starter.git'
7 | type: git
8 | branch: walkthrough
9 | components:
10 | - id: che-incubator/typescript/latest
11 | memoryLimit: 512Mi
12 | type: chePlugin
13 | - mountSources: true
14 | endpoints:
15 | - name: server
16 | port: 4000
17 | - name: devclient
18 | port: 3333
19 | memoryLimit: 1500Mi
20 | type: dockerimage
21 | alias: nodejs
22 | image: 'registry.redhat.io/codeready-workspaces/stacks-node-rhel8:2.0'
23 | env:
24 | - value: 220fd770-c028-480d-8f95-f84353c7d55a
25 | name: SECRET
26 | - endpoints:
27 | - name: mongodb-34-rhel7
28 | port: 27017
29 | attributes:
30 | discoverable: 'true'
31 | public: 'false'
32 | memoryLimit: 512Mi
33 | type: dockerimage
34 | volumes:
35 | - name: mongo-storage
36 | containerPath: /var/lib/mongodb/data
37 | alias: mongo
38 | image: registry.redhat.io/rhscl/mongodb-34-rhel7
39 | env:
40 | - value: user
41 | name: MONGODB_USER
42 | - value: password
43 | name: MONGODB_PASSWORD
44 | - value: showcase
45 | name: MONGODB_DATABASE
46 | - value: password
47 | name: MONGODB_ADMIN_PASSWORD
48 | apiVersion: 1.0.0
49 | commands:
50 | - name: install dependencies
51 | actions:
52 | - workdir: '${CHE_PROJECTS_ROOT}/datasync-starter'
53 | type: exec
54 | command: git checkout walkthrough && npm install -g yarn && yarn install
55 | component: nodejs
56 | - name: generate source code
57 | actions:
58 | - workdir: '${CHE_PROJECTS_ROOT}/datasync-starter'
59 | type: exec
60 | command: 'yarn graphback generate'
61 | component: nodejs
62 | - name: prepare client
63 | actions:
64 | - workdir: '${CHE_PROJECTS_ROOT}/datasync-starter'
65 | type: exec
66 | command: 'yarn prepare:client'
67 | component: nodejs
68 | - name: start server
69 | actions:
70 | - workdir: '${CHE_PROJECTS_ROOT}/datasync-starter'
71 | type: exec
72 | command: >-
73 | MONGO_USER=user MONGO_PASSWORD=password MONGO_HOST=mongodb-34-rhel7
74 | yarn start:server
75 | component: nodejs
76 |
--------------------------------------------------------------------------------
/client/src/components/Task.tsx:
--------------------------------------------------------------------------------
1 | import React, { MouseEvent, SyntheticEvent } from 'react';
2 | import {
3 | IonItem,
4 | IonButton,
5 | IonLabel,
6 | IonNote,
7 | IonBadge,
8 | IonIcon,
9 | IonCheckbox,
10 | IonButtons
11 | } from '@ionic/react';
12 | import { create, trash, navigate } from 'ionicons/icons';
13 | import { ITask } from '../declarations';
14 | import { Link, useHistory } from 'react-router-dom';
15 |
16 | export const Task: React.FC = ({ task, updateTask, deleteTask }) => {
17 | const history = useHistory();
18 | const onDeleteClick = (event: MouseEvent) => {
19 | event.preventDefault();
20 | deleteTask(task);
21 | };
22 |
23 | const onViewClick = (event: MouseEvent) => {
24 | event.preventDefault();
25 | history.push(`/viewTask/${task.id}`);
26 | };
27 |
28 | const check = (event: SyntheticEvent) => {
29 | event.preventDefault();
30 | let status = (task.status === 'COMPLETE') ? 'OPEN' : 'COMPLETE';
31 | updateTask({
32 | ...task,
33 | status
34 | });
35 | }
36 |
37 | const isChecked = (task: ITask) => {
38 | if (task.status === 'COMPLETE') {
39 | return true;
40 | }
41 | return false;
42 | }
43 |
44 | return (
45 |
46 |
47 |
48 | {task.title}
49 |
50 | {task.description}
51 |
52 |
53 |
54 |
55 | Server timestamp: {new Date(Number.parseInt(task.updatedAt)).toUTCString()}
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | );
77 |
78 | };
79 |
--------------------------------------------------------------------------------
/client/src/auth/keycloakAuth.ts:
--------------------------------------------------------------------------------
1 | import Keycloak, { KeycloakInstance } from 'keycloak-js';
2 | import { ILogoutParams } from '../declarations';
3 |
4 | export let keycloak: KeycloakInstance | undefined;
5 |
6 | /**
7 | * Get keycloak instance
8 | *
9 | * @return an initiated keycloak instance or `undefined`
10 | * if keycloak isn't configured
11 | *
12 | */
13 | export const getKeycloakInstance = async () => {
14 | if (!keycloak) await init();
15 | return keycloak;
16 | }
17 |
18 | /**
19 | * Initiate keycloak instance.
20 | *
21 | * Set keycloak to undefined if
22 | * keycloak isn't configured
23 | *
24 | */
25 | export const init = async () => {
26 | try {
27 | keycloak = new (Keycloak as any )();
28 | if (keycloak) {
29 | await keycloak.init({
30 | onLoad: 'login-required'
31 | });
32 | }
33 | } catch {
34 | keycloak = undefined;
35 | console.warn('Auth: Unable to initialize keycloak. Client side will not be configured to use authentication');
36 | }
37 | }
38 |
39 |
40 | /**
41 | * This function keeps getting called by wslink
42 | * connection param function, so carry out
43 | * an early return if keycloak is not initialized
44 | * otherwise get the auth token
45 | *
46 | * @return authorization header or empty string
47 | *
48 | */
49 | export const getAuthHeader = async () => {
50 | if (!keycloak) return '';
51 | return {
52 | 'authorization': `Bearer ${await getKeyCloakToken()}`
53 | };
54 | };
55 |
56 |
57 | /**
58 | * Use keycloak update token function to retrieve
59 | * keycloak token
60 | *
61 | * @return keycloak token or empty string if keycloak
62 | * isn't configured
63 | *
64 | */
65 | const getKeyCloakToken = async () => {
66 | await keycloak?.updateToken(50);
67 | if (keycloak?.token) return keycloak.token;
68 | console.error('No keycloak token available');
69 | return '';
70 | }
71 |
72 | /**
73 | * logout of keycloak, clear cache and offline store then redirect to
74 | * keycloak login page
75 | *
76 | * @param keycloak the keycloak instance
77 | * @param client offix client
78 | *
79 | */
80 | export const logout = async ({ keycloak, client: apolloClient } : ILogoutParams) => {
81 | if(keycloak) {
82 | await keycloak.logout();
83 | // clear offix client offline store
84 | await apolloClient.resetStore();
85 | // clear offix client cache
86 | await apolloClient.cache.reset();
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/client/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState } from 'react';
2 | import { IonHeader, IonToolbar, IonButtons, IonTitle, IonToast, IonButton, IonIcon } from '@ionic/react';
3 | import { person, exit, arrowBack } from 'ionicons/icons';
4 | import { AuthContext } from '../AuthContext';
5 | import { logout } from '../auth/keycloakAuth';
6 | import { useApolloOfflineClient } from 'react-offix-hooks';
7 | import { Link } from 'react-router-dom';
8 |
9 | export const Header : React.FC<{ title: string, backHref?: string, match: any, isOnline?: boolean }> = ({ title, backHref, match, isOnline }) => {
10 |
11 | const { url } = match;
12 |
13 | const client = useApolloOfflineClient();
14 | const { keycloak } = useContext(AuthContext);
15 | const [ showToast, setShowToast ] = useState(false);
16 |
17 | const handleLogout = async () => {
18 | if (isOnline) {
19 | await logout({ keycloak, client });
20 | return;
21 | }
22 | setShowToast(true);
23 | }
24 |
25 | // if keycloak is not configured, don't display logout and
26 | // profile icons. Only show login and profile icons on the home
27 | // screen
28 | const buttons = (!keycloak || url !== '/tasks') ? <>> : (
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 |
41 | return (
42 | <>
43 |
44 |
45 | {
46 | url !== '/tasks' &&
47 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | }
59 | { title }
60 | { buttons }
61 |
62 |
63 | setShowToast(false)}
66 | message="You are currently offline. Unable to logout."
67 | position="top"
68 | color="danger"
69 | duration={1000}
70 | />
71 | >
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/server/src/crudServiceCreator.ts:
--------------------------------------------------------------------------------
1 | import { GraphbackOperationType, CRUDService } from '@graphback/core'
2 | import { KeycloakCrudService, CrudServicesAuthConfig } from '@graphback/keycloak-authz'
3 | import { ModelDefinition, GraphbackCRUDService, CRUDServiceConfig, GraphbackDataProvider } from 'graphback';
4 | import { DataSyncProvider, DataSyncCRUDService } from '@graphback/datasync';
5 | import { connectToPubSub } from './pubsub';
6 | import { config } from "./config/config"
7 | import { authConfig } from "./config/auth"
8 | import { PubSub } from 'graphql-subscriptions';
9 | import e from 'express';
10 |
11 | /**
12 | * Creates Graphback service with following capabilities:
13 | *
14 | * - DataSync
15 | * - Keycloak
16 | * - AMQ custom topics
17 | *
18 | * This functions tries to enable various capabilities based on the config provided
19 | */
20 | export function createCRUDService(globalServiceConfig?: CRUDServiceConfig) {
21 | let pubSub;
22 | if (config.mqttConfig) {
23 | pubSub = connectToPubSub();
24 | } else {
25 | pubSub = new PubSub();
26 | }
27 |
28 | return (model: ModelDefinition, dataProvider: DataSyncProvider): GraphbackCRUDService => {
29 | const serviceConfig: CRUDServiceConfig = {
30 | pubSub,
31 | ...globalServiceConfig,
32 | crudOptions: model.crudOptions
33 | }
34 | let service
35 | if (config.mqttConfig) {
36 | service = new AMQCRUDDataSyncService(model.graphqlType.name, dataProvider, serviceConfig);
37 | } else {
38 | service = new DataSyncCRUDService(model.graphqlType.name, dataProvider, serviceConfig);
39 | }
40 |
41 | if (config.keycloakConfig) {
42 | const objConfig = authConfig[model.graphqlType.name];
43 | const keycloakService = new KeycloakCrudService({ service, authConfig: objConfig });
44 |
45 | return keycloakService;
46 | }
47 |
48 | return service;
49 | }
50 | }
51 |
52 | /**
53 | * Service that allows you to configure how AMQ topics are build
54 | */
55 | export class AMQCRUDDataSyncService extends DataSyncCRUDService {
56 | constructor(modelName: string, db: DataSyncProvider, config: CRUDServiceConfig) {
57 | super(modelName, db, config);
58 | }
59 | protected subscriptionTopicMapping(triggerType: GraphbackOperationType, objectName: string) {
60 | // Support AMQ topic creation format
61 | return `graphql/${objectName}_${triggerType}`
62 | }
63 | }
--------------------------------------------------------------------------------
/client/src/pages/TaskPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { useQuery } from '@apollo/react-hooks';
3 | import { add } from 'ionicons/icons';
4 | import {
5 | IonPage,
6 | IonSegment,
7 | IonSegmentButton,
8 | IonLabel,
9 | IonIcon,
10 | IonFooter,
11 | IonLoading,
12 | IonFab,
13 | IonFabButton,
14 | IonContent,
15 | } from '@ionic/react';
16 | import { subscriptionOptions, } from '../helpers';
17 | import { Empty, TaskList, NetworkBadge, OfflineQueueBadge, Header } from '../components';
18 | import { RouteComponentProps } from 'react-router';
19 | import { findTasks } from '../graphql/generated';
20 | import { Link } from 'react-router-dom';
21 | import { useNetworkStatus } from 'react-offix-hooks';
22 |
23 | export const TaskPage: React.FC = ({match}) => {
24 |
25 | const [subscribed, setSubscribed] = useState(false);
26 | const { loading, error, data, subscribeToMore } = useQuery(findTasks, {
27 | fetchPolicy: 'cache-and-network'
28 | });
29 |
30 | const isOnline = useNetworkStatus();
31 |
32 | useEffect(() => {
33 | if (!subscribed) {
34 | subscribeToMore(subscriptionOptions.add);
35 | subscribeToMore(subscriptionOptions.edit);
36 | subscribeToMore(subscriptionOptions.remove);
37 | setSubscribed(true);
38 | }
39 | }, [subscribed, setSubscribed, subscribeToMore])
40 |
41 | if (error && !error.networkError) {
42 | return { JSON.stringify(error) }
43 | };
44 |
45 | if (loading) return ;
49 |
50 | const content = (data && data.findTasks && data.findTasks.items)
51 | ?
52 | : No tasks available
} />;
53 |
54 | return (
55 |
56 |
57 |
58 |
59 |
60 | All Tasks
61 |
62 |
63 | { content }
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | );
80 |
81 | };
82 |
--------------------------------------------------------------------------------
/client/src/theme/variables.css:
--------------------------------------------------------------------------------
1 | /* Ionic Variables and Theming. For more info, please see:
2 | http://ionicframework.com/docs/theming/ */
3 |
4 | /** Ionic CSS Variables **/
5 | :root {
6 | /** primary **/
7 | --ion-color-primary: #3880ff;
8 | --ion-color-primary-rgb: 56, 128, 255;
9 | --ion-color-primary-contrast: #ffffff;
10 | --ion-color-primary-contrast-rgb: 255, 255, 255;
11 | --ion-color-primary-shade: #3171e0;
12 | --ion-color-primary-tint: #4c8dff;
13 |
14 | /** secondary **/
15 | --ion-color-secondary: #0cd1e8;
16 | --ion-color-secondary-rgb: 12, 209, 232;
17 | --ion-color-secondary-contrast: #ffffff;
18 | --ion-color-secondary-contrast-rgb: 255, 255, 255;
19 | --ion-color-secondary-shade: #0bb8cc;
20 | --ion-color-secondary-tint: #24d6ea;
21 |
22 | /** tertiary **/
23 | --ion-color-tertiary: #7044ff;
24 | --ion-color-tertiary-rgb: 112, 68, 255;
25 | --ion-color-tertiary-contrast: #ffffff;
26 | --ion-color-tertiary-contrast-rgb: 255, 255, 255;
27 | --ion-color-tertiary-shade: #633ce0;
28 | --ion-color-tertiary-tint: #7e57ff;
29 |
30 | /** success **/
31 | --ion-color-success: #10dc60;
32 | --ion-color-success-rgb: 16, 220, 96;
33 | --ion-color-success-contrast: #ffffff;
34 | --ion-color-success-contrast-rgb: 255, 255, 255;
35 | --ion-color-success-shade: #0ec254;
36 | --ion-color-success-tint: #28e070;
37 |
38 | /** warning **/
39 | --ion-color-warning: #ffce00;
40 | --ion-color-warning-rgb: 255, 206, 0;
41 | --ion-color-warning-contrast: #ffffff;
42 | --ion-color-warning-contrast-rgb: 255, 255, 255;
43 | --ion-color-warning-shade: #e0b500;
44 | --ion-color-warning-tint: #ffd31a;
45 |
46 | /** danger **/
47 | --ion-color-danger: #f04141;
48 | --ion-color-danger-rgb: 245, 61, 61;
49 | --ion-color-danger-contrast: #ffffff;
50 | --ion-color-danger-contrast-rgb: 255, 255, 255;
51 | --ion-color-danger-shade: #d33939;
52 | --ion-color-danger-tint: #f25454;
53 |
54 | /** dark **/
55 | --ion-color-dark: #222428;
56 | --ion-color-dark-rgb: 34, 34, 34;
57 | --ion-color-dark-contrast: #ffffff;
58 | --ion-color-dark-contrast-rgb: 255, 255, 255;
59 | --ion-color-dark-shade: #1e2023;
60 | --ion-color-dark-tint: #383a3e;
61 |
62 | /** medium **/
63 | --ion-color-medium: #989aa2;
64 | --ion-color-medium-rgb: 152, 154, 162;
65 | --ion-color-medium-contrast: #ffffff;
66 | --ion-color-medium-contrast-rgb: 255, 255, 255;
67 | --ion-color-medium-shade: #86888f;
68 | --ion-color-medium-tint: #a2a4ab;
69 |
70 | /** light **/
71 | --ion-color-light: #f4f5f8;
72 | --ion-color-light-rgb: 244, 244, 244;
73 | --ion-color-light-contrast: #000000;
74 | --ion-color-light-contrast-rgb: 0, 0, 0;
75 | --ion-color-light-shade: #d7d8da;
76 | --ion-color-light-tint: #f5f6f9;
77 | }
78 |
--------------------------------------------------------------------------------
/client/src/pages/ProfilePage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import {
3 | IonContent,
4 | IonCard,
5 | IonCardHeader,
6 | IonCardTitle,
7 | IonCardSubtitle,
8 | IonCardContent,
9 | IonItemGroup,
10 | IonItemDivider,
11 | IonList,
12 | IonItem,
13 | IonLabel
14 | } from '@ionic/react';
15 | import { Header } from '../components';
16 | import { AuthContext } from '../AuthContext';
17 | import { RouteComponentProps } from 'react-router';
18 |
19 | const userInit = {
20 | username: 'unknown',
21 | email: 'unknown',
22 | firstName: 'unknown',
23 | lastName: 'unknown',
24 | emailVerified: false,
25 | }
26 |
27 | export const ProfilePage: React.FC = ({ match }) => {
28 | const { keycloak, profile } = useContext(AuthContext);
29 |
30 | if (!keycloak || !profile) return (
31 |
32 |
33 | Authentication not configured
34 | IDM service required
35 |
36 |
37 | Profile page cannot be displayed.
38 | Please enable Auth SDK by providing configuration pointing to your IDM service
39 |
40 |
41 | );
42 |
43 | const user = Object.assign(userInit, profile);
44 |
45 | const fullName = (user.firstName !== 'unknown' && user.lastName !== 'unknown')
46 | ? `${user.firstName} ${user.lastName}`
47 | : 'unknown';
48 |
49 | const roles = keycloak.realmAccess?.roles.map((role, index) => {
50 | return {role}
51 | });
52 |
53 | return (
54 | <>
55 |
56 |
57 |
58 |
59 |
60 | Provider
61 |
62 |
63 | Full Name: {fullName}
64 |
65 |
66 |
67 | Email: {user.email}
68 |
69 |
70 |
71 | Username: {user.username}
72 |
73 |
74 |
75 | Email Verified: {user.emailVerified ? 'Yes' : 'No'}
76 |
77 |
78 |
79 |
80 |
81 | Assigned Roles
82 |
83 | {roles}
84 |
85 |
86 |
87 |
88 | >
89 | );
90 |
91 | };
92 |
--------------------------------------------------------------------------------
/client/src/pages/UpdateTaskPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { RouteComponentProps } from 'react-router-dom'
3 | import {
4 | IonContent,
5 | IonLoading,
6 | IonToast,
7 | IonCard,
8 | } from '@ionic/react';
9 | import { useOfflineMutation } from 'react-offix-hooks';
10 | import { useQuery } from '@apollo/react-hooks';
11 | import { Header } from '../components/Header';
12 | import { Empty } from '../components/Empty';
13 | import { getTask, updateTask } from '../graphql/generated';
14 | import { TaskForm } from '../forms/TaskForm';
15 | import { subscriptionOptions, mutationOptions } from '../helpers';
16 |
17 | export interface IUpdateMatchParams {
18 | id: string
19 | }
20 |
21 | export const UpdateTaskPage: React.FC> = ({ history, match }) => {
22 |
23 | const { id } = match.params;
24 | const [mounted, setMounted] = useState(false);
25 | const [showToast, setShowToast] = useState(false);
26 | const [errorMessage, setErrorMessage] = useState('');
27 | const { loading, error, data, subscribeToMore } = useQuery(getTask, {
28 | variables: { id },
29 | fetchPolicy: 'cache-only',
30 | });
31 |
32 | useEffect(() => {
33 | if (mounted) {
34 | subscribeToMore(subscriptionOptions.addComment)
35 | }
36 | setMounted(true);
37 | return () => setMounted(false);
38 | }, [mounted, setMounted, subscribeToMore]);
39 |
40 | const [updateTaskMutation] = useOfflineMutation(
41 | updateTask, mutationOptions.updateTask,
42 | );
43 |
44 | const handleError = (error: any) => {
45 | if (error.offline) {
46 | error.watchOfflineChange();
47 | history.push('/');
48 | return;
49 | }
50 | console.log(error);
51 | setErrorMessage(error.message);
52 | setShowToast(true);
53 | }
54 |
55 | const submit = (model: any) => {
56 | const { __typename, comments, createdAt, ...input } = model;
57 | updateTaskMutation({
58 | variables: { input }
59 | })
60 | .then(() => history.push('/'))
61 | .catch(handleError);
62 | }
63 |
64 | if (error) return {JSON.stringify(error)};
65 |
66 | if (loading) return ;
70 |
71 | if (data && data.getTask) {
72 | const task = data.getTask;
73 | return (
74 | <>
75 |
76 |
77 |
78 |
79 |
80 | setShowToast(false)}
83 | message={errorMessage}
84 | position="top"
85 | color="danger"
86 | duration={2000}
87 | />
88 |
89 | >
90 | )
91 | };
92 |
93 | return (
94 | <>
95 |
96 | No task found} />
97 | >
98 | );
99 |
100 | }
101 |
--------------------------------------------------------------------------------
/client/src/forms/task.ts:
--------------------------------------------------------------------------------
1 | import { SimpleSchema2Bridge } from "uniforms-bridge-simple-schema-2";
2 | import { LongTextField } from "uniforms-ionic";
3 | import SimpleSchema from "../config/SimpleSchema";
4 |
5 | const taskForm = {
6 | title: {
7 | type: String
8 | },
9 | description: {
10 | type: String,
11 | uniforms: {
12 | component: LongTextField,
13 | },
14 | },
15 |
16 | status: {
17 | type: String,
18 | defaultValue: "OPEN",
19 | allowedValues: ["OPEN", "ASSIGNED", "COMPLETE"],
20 | },
21 |
22 | startDate: {
23 | type: Date,
24 | defaultValue: new Date()
25 | },
26 |
27 | public: {
28 | type: Boolean,
29 | required: false
30 | },
31 |
32 | type: {
33 | type: String,
34 | allowedValues: ["External", "ByAppointment", "Remote"],
35 | required: false
36 | },
37 |
38 | priority: {
39 | type: Number,
40 | allowedValues: [1, 2, 3, 4, 5],
41 | required: false
42 | },
43 |
44 | } as any
45 |
46 | const taskView = {
47 | title: {
48 | type: String,
49 | uniforms: {
50 | readonly: true
51 | }
52 | },
53 | description: {
54 | type: String,
55 | uniforms: {
56 | component: LongTextField,
57 | readonly: true
58 | },
59 | },
60 | status: {
61 | type: String,
62 | defaultValue: "OPEN",
63 | allowedValues: ["OPEN", "ASSIGNED", "COMPLETE"],
64 | required: false,
65 | uniforms: {
66 | readonly: true
67 | }
68 | },
69 |
70 | startDate: {
71 | type: Date,
72 | required: false,
73 | defaultValue: new Date(),
74 | uniforms: {
75 | readonly: true
76 | }
77 | },
78 |
79 | public: {
80 | required: false,
81 | type: Boolean,
82 | uniforms: {
83 | readonly: true
84 | }
85 | },
86 |
87 | type: {
88 | type: String,
89 | allowedValues: ["External", "ByAppointment", "Remote"],
90 | required: false,
91 | uniforms: {
92 | readonly: true
93 | }
94 | },
95 |
96 | priority: {
97 | type: Number,
98 | allowedValues: [1, 2, 3, 4, 5],
99 | required: false,
100 | uniforms: {
101 | readonly: true
102 | }
103 | },
104 |
105 | lastUpdated: {
106 | type: String,
107 | defaultValue: "0",
108 | uniforms: {
109 | readonly: true
110 | }
111 | },
112 |
113 | comments: {
114 | type: Array,
115 | required: false
116 | },
117 |
118 | 'comments.$': {
119 | type: Object,
120 | uniforms: {
121 | readonly: true
122 | }
123 | },
124 |
125 | 'comments.$.message': {
126 | type: String,
127 | uniforms: {
128 | component: LongTextField,
129 | readonly: true
130 | }
131 | }
132 | } as any
133 |
134 |
135 | const commentForm = {
136 | author: {
137 | type: String,
138 | uniforms: {
139 | readonly: true
140 | }
141 | },
142 | message: {
143 | type: String,
144 | uniforms: {
145 | component: LongTextField,
146 | }
147 | }
148 | } as any
149 |
150 | const schemaEdit = new SimpleSchema(taskForm)
151 | export const taskEditSchema = new SimpleSchema2Bridge(schemaEdit);
152 |
153 | const schemaView = new SimpleSchema(taskView)
154 | export const taskViewSchema = new SimpleSchema2Bridge(schemaView);
155 |
156 | const commentEdit = new SimpleSchema(commentForm)
157 | export const commentViewSchema = new SimpleSchema2Bridge(commentEdit);
--------------------------------------------------------------------------------
/client/src/config/clientConfig.ts:
--------------------------------------------------------------------------------
1 | import { ApolloLink } from 'apollo-link';
2 | import { HttpLink } from 'apollo-link-http';
3 | import { WebSocketLink } from 'apollo-link-ws';
4 | import { setContext } from 'apollo-link-context';
5 | import { getMainDefinition } from 'apollo-utilities';
6 | import { InMemoryCache } from 'apollo-cache-inmemory';
7 | import { globalCacheUpdates, ConflictLogger } from '../helpers';
8 | import { getAuthHeader } from '../auth/keycloakAuth';
9 | import { ApolloOfflineClientOptions } from 'offix-client';
10 | import { Capacitor } from '@capacitor/core';
11 | import { CapacitorNetworkStatus } from '../helpers/CapacitorNetworkStatus';
12 | import { TimeStampState } from './conflictStrategy';
13 |
14 |
15 | let httpUri = 'http://localhost:4000/graphql';
16 | let wsUri = 'ws://localhost:4000/graphql';
17 |
18 | if (Capacitor.isNative && Capacitor.platform === 'android') {
19 | httpUri = 'http://10.0.2.2:4000/graphql';
20 | wsUri = 'ws://10.0.2.2:4000/graphql';
21 | }
22 |
23 | if (process.env.REACT_APP_URI_FORMAT === 'RELATIVEURI') {
24 | httpUri = "/graphql";
25 | const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
26 | const port = window.location.port !== "" ? `:${window.location.port}` : "";
27 | wsUri = `${protocol}${window.location.hostname}${port}${httpUri}`
28 | }
29 |
30 | /**
31 | * Create websocket link and
32 | * define websocket link options
33 | */
34 | const wsLink = new WebSocketLink({
35 | uri: wsUri,
36 | options: {
37 | reconnect: true,
38 | lazy: true,
39 | // returns auth header or empty string
40 | connectionParams: async () => (await getAuthHeader())
41 | },
42 | });
43 |
44 | const httpLink = new HttpLink({
45 | uri: httpUri,
46 | });
47 |
48 | /**
49 | * add authorization headers for queries
50 | * to grapqhql backend
51 | *
52 | */
53 | const authLink = setContext(async (_, { headers }) => {
54 | return {
55 | headers: {
56 | ...headers,
57 | // returns auth header or empty string
58 | ...await getAuthHeader()
59 | }
60 | }
61 | });
62 |
63 | /**
64 | * split queries and subscriptions.
65 | * send subscriptions to websocket url &
66 | * queries to http url
67 | *
68 | */
69 | const splitLink = ApolloLink.split(
70 | ({ query }) => {
71 | const { kind, operation }: any = getMainDefinition(query);
72 | return kind === 'OperationDefinition' && operation === 'subscription';
73 | },
74 | wsLink,
75 | httpLink,
76 | );
77 |
78 | /**
79 | * Instantiate cache object
80 | * and define cache redirect queries
81 | *
82 | */
83 | const cache = new InMemoryCache({
84 | // cache redirects are used
85 | // to query the cache for individual Task item
86 | cacheRedirects: {
87 | Query: {
88 | getTask: (_, { id }, { getCacheKey }) => getCacheKey({ __typename: 'Task', id }),
89 | },
90 | },
91 | });
92 |
93 | export const clientConfig: ApolloOfflineClientOptions = {
94 | link: authLink.concat(splitLink),
95 | cache: cache,
96 | conflictListener: new ConflictLogger(),
97 | mutationCacheUpdates: globalCacheUpdates,
98 | networkStatus: new CapacitorNetworkStatus(),
99 | conflictProvider: new TimeStampState(),
100 | inputMapper: {
101 | deserialize: (variables: any) => {
102 | return (variables && variables.input) ? variables.input : variables;
103 | },
104 | serialize: (variables: any) => {
105 | return { input: variables }
106 | }
107 | }
108 | };
109 |
--------------------------------------------------------------------------------
/client/src/pages/ViewTaskPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState, useEffect } from 'react'
2 | import { RouteComponentProps } from 'react-router-dom'
3 | import { IonContent, IonLoading, IonCard, IonCardHeader, IonCardTitle, IonCardContent, IonList, IonItemGroup, IonItem, IonLabel, IonAvatar, IonToast } from '@ionic/react';
4 | import { Header } from '../components/Header';
5 | import { useQuery, useMutation } from '@apollo/react-hooks';
6 | import { Empty } from '../components';
7 | import { createComment, getTask, findTasks } from '../graphql/generated';
8 | import { commentViewSchema, taskViewSchema } from '../forms/task';
9 | import { AutoForm, TextField } from "uniforms-ionic";
10 | import { AuthContext } from '../AuthContext';
11 | import { subscriptionOptions } from '../helpers';
12 | import { useNetworkStatus } from 'react-offix-hooks';
13 |
14 | export interface ViewMatchParams {
15 | id: string
16 | }
17 |
18 | export const ViewTaskPage: React.FC> = ({ history, match }) => {
19 | const [showToast, setShowToast] = useState(false);
20 | const offline = !useNetworkStatus()
21 | const { id } = match.params;
22 | const [mounted, setMounted] = useState(false);
23 | const { profile } = useContext(AuthContext);
24 | const [createCommentMutation] = useMutation(
25 | createComment, { refetchQueries: [{ query: findTasks }] }
26 | );
27 | const userName = profile?.username || "Anonymous User";
28 |
29 | const submit = (model: any) => {
30 | if (offline) {
31 | setShowToast(offline)
32 | } else {
33 | createCommentMutation({
34 | variables: { input: { ...model, noteId: id } }
35 | }).then((comment) => {
36 | console.log("comment created")
37 | }).catch((error) => {
38 | console.log(error)
39 | })
40 | }
41 |
42 | }
43 |
44 | const { loading, error, data, subscribeToMore } = useQuery(getTask, {
45 | variables: { id },
46 | fetchPolicy: 'cache-only',
47 | });
48 |
49 | useEffect(() => {
50 | if (mounted) {
51 | subscribeToMore(subscriptionOptions.addComment)
52 | }
53 | setMounted(true);
54 | return () => setMounted(false);
55 | }, [mounted, setMounted, subscribeToMore]);
56 |
57 | if (error) return {JSON.stringify(error)};
58 |
59 | if (loading) return ;
63 |
64 | if (data && data.getTask) {
65 | const task = data.getTask;
66 | const Text = TextField as any;
67 | return (
68 | <>
69 |
70 |
71 |
72 |
73 | Current Task
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | Create comment
83 |
84 |
85 |
86 |
87 |
88 | Comments
89 |
90 |
91 |
92 |
93 | {
94 | task.comments && task.comments.map((comment: any, key: number) => {
95 | return (
96 |
97 |
98 |
99 |
100 |
101 | {comment.author}
102 | {comment.message}
103 |
104 |
105 | );
106 | })
107 | }
108 |
109 |
110 |
111 |
112 | setShowToast(false)}
115 | message="Cannot add comment when offline"
116 | position="bottom"
117 | color="danger"
118 | duration={4000}
119 | />
120 |
121 | >
122 | )
123 | }
124 | return (
125 | <>
126 |
127 | No task found} />
128 | >
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
/client/src/graphql/generated.ts:
--------------------------------------------------------------------------------
1 | import gql from "graphql-tag"
2 |
3 | export const TaskFragment = gql`
4 | fragment TaskFields on Task {
5 | id
6 | title
7 | description
8 | status
9 | type
10 | priority
11 | public
12 | startDate
13 | payload
14 | updatedAt
15 | }
16 | `
17 |
18 |
19 | export const TaskExpandedFragment = gql`
20 | fragment TaskExpandedFields on Task {
21 | id
22 | title
23 | description
24 | status
25 | type
26 | priority
27 | public
28 | startDate
29 | payload
30 | comments {
31 | id
32 | message
33 | author
34 | }
35 | updatedAt
36 | }
37 | `
38 |
39 |
40 |
41 |
42 | export const CommentFragment = gql`
43 | fragment CommentFields on Comment {
44 | id
45 | message
46 | author
47 |
48 | }
49 | `
50 |
51 |
52 | export const CommentExpandedFragment = gql`
53 | fragment CommentExpandedFields on Comment {
54 | id
55 | message
56 | author
57 | note {
58 | id
59 | title
60 | description
61 | status
62 | type
63 | priority
64 | public
65 | startDate
66 | payload
67 | }
68 | updatedAt
69 | }
70 | `
71 |
72 |
73 | export const findTasks = gql`
74 | query findTasks($filter: TaskFilter, $page: PageRequest, $orderBy: OrderByInput) {
75 | findTasks(filter: $filter, page: $page, orderBy: $orderBy) {
76 | items {
77 | ...TaskExpandedFields
78 | }
79 | offset
80 | limit
81 | count
82 | }
83 | }
84 |
85 | ${TaskExpandedFragment}
86 | `
87 |
88 |
89 | export const getTask = gql`
90 | query getTask($id: ID!) {
91 | getTask(id: $id) {
92 | ...TaskExpandedFields
93 | }
94 | }
95 |
96 | ${TaskExpandedFragment}
97 | `
98 |
99 |
100 | export const findComments = gql`
101 | query findComments($filter: CommentFilter, $page: PageRequest, $orderBy: OrderByInput) {
102 | findComments(filter: $filter, page: $page, orderBy: $orderBy) {
103 | items {
104 | ...CommentExpandedFields
105 | }
106 | offset
107 | limit
108 | count
109 | }
110 | }
111 |
112 | ${CommentExpandedFragment}
113 | `
114 |
115 |
116 | export const getComment = gql`
117 | query getComment($id: ID!) {
118 | getComment(id: $id) {
119 | ...CommentExpandedFields
120 | }
121 | }
122 |
123 | ${CommentExpandedFragment}
124 | `
125 |
126 |
127 | export const createTask = gql`
128 | mutation createTask($input: CreateTaskInput!) {
129 | createTask(input: $input) {
130 | ...TaskFields
131 | }
132 | }
133 |
134 |
135 | ${TaskFragment}
136 | `
137 |
138 |
139 | export const updateTask = gql`
140 | mutation updateTask($input: MutateTaskInput!) {
141 | updateTask(input: $input) {
142 | ...TaskFields
143 | }
144 | }
145 |
146 |
147 | ${TaskFragment}
148 | `
149 |
150 |
151 | export const deleteTask = gql`
152 | mutation deleteTask($input: MutateTaskInput!) {
153 | deleteTask(input: $input) {
154 | ...TaskFields
155 | }
156 | }
157 |
158 |
159 | ${TaskFragment}
160 | `
161 |
162 |
163 | export const createComment = gql`
164 | mutation createComment($input: CreateCommentInput!) {
165 | createComment(input: $input) {
166 | ...CommentFields
167 | }
168 | }
169 |
170 |
171 | ${CommentFragment}
172 | `
173 |
174 |
175 | export const updateComment = gql`
176 | mutation updateComment($input: MutateCommentInput!) {
177 | updateComment(input: $input) {
178 | ...CommentFields
179 | }
180 | }
181 |
182 |
183 | ${CommentFragment}
184 | `
185 |
186 |
187 | export const deleteComment = gql`
188 | mutation deleteComment($input: MutateCommentInput!) {
189 | deleteComment(input: $input) {
190 | ...CommentFields
191 | }
192 | }
193 |
194 |
195 | ${CommentFragment}
196 | `
197 |
198 |
199 | export const newTask = gql`
200 | subscription newTask($filter: TaskSubscriptionFilter) {
201 | newTask(filter: $filter) {
202 | ...TaskExpandedFields
203 | }
204 | }
205 |
206 | ${TaskExpandedFragment}
207 | `
208 |
209 |
210 | export const updatedTask = gql`
211 | subscription updatedTask($filter: TaskSubscriptionFilter) {
212 | updatedTask(filter: $filter) {
213 | ...TaskFields
214 | }
215 | }
216 |
217 | ${TaskFragment}
218 | `
219 |
220 |
221 | export const deletedTask = gql`
222 | subscription deletedTask($filter: TaskSubscriptionFilter) {
223 | deletedTask(filter: $filter) {
224 | ...TaskFields
225 | }
226 | }
227 |
228 | ${TaskFragment}
229 | `
230 |
231 |
232 | export const newComment = gql`
233 | subscription newComment($filter: CommentSubscriptionFilter) {
234 | newComment(filter: $filter) {
235 | ...CommentFields
236 | }
237 | }
238 |
239 | ${CommentFragment}
240 | `
241 |
242 |
243 | export const updatedComment = gql`
244 | subscription updatedComment($filter: CommentSubscriptionFilter) {
245 | updatedComment(filter: $filter) {
246 | ...CommentFields
247 | }
248 | }
249 |
250 | ${CommentFragment}
251 | `
252 |
253 |
254 | export const deletedComment = gql`
255 | subscription deletedComment($filter: CommentSubscriptionFilter) {
256 | deletedComment(filter: $filter) {
257 | ...CommentFields
258 | }
259 | }
260 |
261 | ${CommentFragment}
262 | `
263 |
--------------------------------------------------------------------------------
/server/src/schema/schema.graphql:
--------------------------------------------------------------------------------
1 | ## NOTE: This schema was generated by Graphback and should not be changed manually
2 |
3 | """Exposes a URL that specifies the behaviour of this scalar."""
4 | directive @specifiedBy(
5 | """The URL that specifies the behaviour of this scalar."""
6 | url: String!
7 | ) on SCALAR
8 |
9 | input BooleanInput {
10 | ne: Boolean
11 | eq: Boolean
12 | }
13 |
14 | """
15 | @model
16 | @crud(delete: false)
17 | @crud(update: false)
18 | """
19 | type Comment {
20 | """@id"""
21 | id: ObjectID!
22 | message: String!
23 | author: String!
24 |
25 | """@manyToOne(field: 'comments', key: 'noteId')"""
26 | note: Task
27 | }
28 |
29 | input CommentFilter {
30 | id: ObjectIDInput
31 | message: StringInput
32 | author: StringInput
33 | noteId: ObjectIDInput
34 | and: [CommentFilter]
35 | or: [CommentFilter]
36 | not: CommentFilter
37 | }
38 |
39 | type CommentResultList {
40 | items: [Comment]!
41 | offset: Int
42 | limit: Int
43 | count: Int
44 | }
45 |
46 | input CommentSubscriptionFilter {
47 | id: ObjectID
48 | message: String
49 | author: String
50 | }
51 |
52 | input CreateCommentInput {
53 | id: ObjectID
54 | message: String!
55 | author: String!
56 | noteId: ObjectID
57 | }
58 |
59 | input CreateTaskInput {
60 | id: ObjectID
61 | title: String!
62 | description: String!
63 | status: TaskStatus
64 | type: String
65 | priority: Int
66 | public: Boolean
67 | startDate: DateTime
68 | payload: JSON
69 | }
70 |
71 | scalar DateTime
72 |
73 | input DateTimeInput {
74 | ne: DateTime
75 | eq: DateTime
76 | le: DateTime
77 | lt: DateTime
78 | ge: DateTime
79 | gt: DateTime
80 | in: [DateTime]
81 | between: [DateTime]
82 | }
83 |
84 | input IntInput {
85 | ne: Int
86 | eq: Int
87 | le: Int
88 | lt: Int
89 | ge: Int
90 | gt: Int
91 | in: [Int]
92 | between: [Int]
93 | }
94 |
95 | scalar JSON
96 |
97 | input JSONInput {
98 | ne: JSON
99 | eq: JSON
100 | le: JSON
101 | lt: JSON
102 | ge: JSON
103 | gt: JSON
104 | in: [JSON]
105 | between: [JSON]
106 | }
107 |
108 | input MutateCommentInput {
109 | id: ObjectID!
110 | message: String
111 | author: String
112 | noteId: ObjectID
113 | }
114 |
115 | input MutateTaskInput {
116 | id: ObjectID!
117 | title: String
118 | description: String
119 | status: TaskStatus
120 | type: String
121 | priority: Int
122 | public: Boolean
123 | startDate: DateTime
124 | payload: JSON
125 | }
126 |
127 | type Mutation {
128 | createTask(input: CreateTaskInput!): Task!
129 | updateTask(input: MutateTaskInput!): Task!
130 | deleteTask(input: MutateTaskInput!): Task!
131 | createComment(input: CreateCommentInput!): Comment!
132 | updateComment(input: MutateCommentInput!): Comment!
133 | deleteComment(input: MutateCommentInput!): Comment!
134 | }
135 |
136 | scalar ObjectID
137 |
138 | input ObjectIDInput {
139 | ne: ObjectID
140 | eq: ObjectID
141 | le: ObjectID
142 | lt: ObjectID
143 | ge: ObjectID
144 | gt: ObjectID
145 | in: [ObjectID]
146 | between: [ObjectID]
147 | }
148 |
149 | input OrderByInput {
150 | field: String!
151 | order: SortDirectionEnum = ASC
152 | }
153 |
154 | input PageRequest {
155 | limit: Int
156 | offset: Int
157 | }
158 |
159 | type Query {
160 | getTask(id: ID!): Task
161 | findTasks(filter: TaskFilter, page: PageRequest, orderBy: OrderByInput): TaskResultList!
162 | getComment(id: ID!): Comment
163 | findComments(filter: CommentFilter, page: PageRequest, orderBy: OrderByInput): CommentResultList!
164 | }
165 |
166 | enum SortDirectionEnum {
167 | DESC
168 | ASC
169 | }
170 |
171 | input StringInput {
172 | ne: String
173 | eq: String
174 | le: String
175 | lt: String
176 | ge: String
177 | gt: String
178 | in: [String]
179 | contains: String
180 | startsWith: String
181 | endsWith: String
182 | }
183 |
184 | type Subscription {
185 | newTask(filter: TaskSubscriptionFilter): Task!
186 | updatedTask(filter: TaskSubscriptionFilter): Task!
187 | deletedTask(filter: TaskSubscriptionFilter): Task!
188 | newComment(filter: CommentSubscriptionFilter): Comment!
189 | updatedComment(filter: CommentSubscriptionFilter): Comment!
190 | deletedComment(filter: CommentSubscriptionFilter): Comment!
191 | }
192 |
193 | """
194 | @model
195 | @datasync
196 | """
197 | type Task {
198 | """@id"""
199 | id: ObjectID!
200 | title: String!
201 | description: String!
202 | status: TaskStatus
203 | type: String
204 | priority: Int
205 | public: Boolean
206 | startDate: DateTime
207 | payload: JSON
208 |
209 | """@oneToMany(field: 'note', key: 'noteId')"""
210 | comments(filter: CommentFilter): [Comment]!
211 | }
212 |
213 | input TaskFilter {
214 | id: ObjectIDInput
215 | title: StringInput
216 | description: StringInput
217 | status: StringInput
218 | type: StringInput
219 | priority: IntInput
220 | public: BooleanInput
221 | startDate: DateTimeInput
222 | payload: JSONInput
223 | and: [TaskFilter]
224 | or: [TaskFilter]
225 | not: TaskFilter
226 | }
227 |
228 | type TaskResultList {
229 | items: [Task]!
230 | offset: Int
231 | limit: Int
232 | count: Int
233 | }
234 |
235 | enum TaskStatus {
236 | OPEN
237 | ASSIGNED
238 | COMPLETE
239 | }
240 |
241 | input TaskSubscriptionFilter {
242 | id: ObjectID
243 | title: String
244 | description: String
245 | status: TaskStatus
246 | type: String
247 | priority: Int
248 | public: Boolean
249 | startDate: DateTime
250 | payload: JSON
251 | }
--------------------------------------------------------------------------------
/client/src/serviceWorker.ts:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | type Config = {
24 | onSuccess?: (registration: ServiceWorkerRegistration) => void;
25 | onUpdate?: (registration: ServiceWorkerRegistration) => void;
26 | };
27 |
28 | export function register(config?: Config) {
29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
30 | // The URL constructor is available in all browsers that support SW.
31 | const publicUrl = new URL(
32 | process.env.PUBLIC_URL,
33 | window.location.href
34 | );
35 | if (publicUrl.origin !== window.location.origin) {
36 | // Our service worker won't work if PUBLIC_URL is on a different origin
37 | // from what our page is served on. This might happen if a CDN is used to
38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
39 | return;
40 | }
41 |
42 | window.addEventListener('load', () => {
43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
44 |
45 | if (isLocalhost) {
46 | // This is running on localhost. Let's check if a service worker still exists or not.
47 | checkValidServiceWorker(swUrl, config);
48 |
49 | // Add some additional logging to localhost, pointing developers to the
50 | // service worker/PWA documentation.
51 | navigator.serviceWorker.ready.then(() => {
52 | console.log(
53 | 'This web app is being served cache-first by a service ' +
54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
55 | );
56 | });
57 | } else {
58 | // Is not localhost. Just register service worker
59 | registerValidSW(swUrl, config);
60 | }
61 | });
62 | }
63 | }
64 |
65 | function registerValidSW(swUrl: string, config?: Config) {
66 | navigator.serviceWorker
67 | .register(swUrl)
68 | .then(registration => {
69 | registration.onupdatefound = () => {
70 | const installingWorker = registration.installing;
71 | if (installingWorker == null) {
72 | return;
73 | }
74 | installingWorker.onstatechange = () => {
75 | if (installingWorker.state === 'installed') {
76 | if (navigator.serviceWorker.controller) {
77 | // At this point, the updated precached content has been fetched,
78 | // but the previous service worker will still serve the older
79 | // content until all client tabs are closed.
80 | console.log(
81 | 'New content is available and will be used when all ' +
82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
83 | );
84 |
85 | // Execute callback
86 | if (config && config.onUpdate) {
87 | config.onUpdate(registration);
88 | }
89 | } else {
90 | // At this point, everything has been precached.
91 | // It's the perfect time to display a
92 | // "Content is cached for offline use." message.
93 | console.log('Content is cached for offline use.');
94 |
95 | // Execute callback
96 | if (config && config.onSuccess) {
97 | config.onSuccess(registration);
98 | }
99 | }
100 | }
101 | };
102 | };
103 | })
104 | .catch(error => {
105 | console.error('Error during service worker registration:', error);
106 | });
107 | }
108 |
109 | function checkValidServiceWorker(swUrl: string, config?: Config) {
110 | // Check if the service worker can be found. If it can't reload the page.
111 | fetch(swUrl, {
112 | headers: { 'Service-Worker': 'script' }
113 | })
114 | .then(response => {
115 | // Ensure service worker exists, and that we really are getting a JS file.
116 | const contentType = response.headers.get('content-type');
117 | if (
118 | response.status === 404 ||
119 | (contentType != null && contentType.indexOf('javascript') === -1)
120 | ) {
121 | // No service worker found. Probably a different app. Reload the page.
122 | navigator.serviceWorker.ready.then(registration => {
123 | registration.unregister().then(() => {
124 | window.location.reload();
125 | });
126 | });
127 | } else {
128 | // Service worker found. Proceed as normal.
129 | registerValidSW(swUrl, config);
130 | }
131 | })
132 | .catch(() => {
133 | console.log(
134 | 'No internet connection found. App is running in offline mode.'
135 | );
136 | });
137 | }
138 |
139 | export function unregister() {
140 | if ('serviceWorker' in navigator) {
141 | navigator.serviceWorker.ready.then(registration => {
142 | registration.unregister();
143 | });
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/.openshift/datasync-app-template.yml:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | apiVersion: template.openshift.io/v1
4 | kind: Template
5 | labels:
6 | template: datasync-starter-server
7 | metadata:
8 | name: datasync-starter-server
9 | annotations:
10 | openshift.io/display-name: DataSync Starter
11 | description: |-
12 | This template allows for the deployment of the Data Sync Example DataSync Starter App.
13 | The Data Sync Example App contains an example Node.js server implementation that
14 | connects to a MongoDB database.
15 | For more information, see https://github.com/aerogear/datasync-starter/
16 | tags: sync, mobile, nodejs
17 | iconClass: icon-nodejs
18 | openshift.io/provider-display-name: Red Hat, Inc.
19 | openshift.io/documentation-url: https://access.redhat.com/documentation/en-us/red_hat_managed_integration/1/html-single/developing_a_data_sync_app/index
20 | openshift.io/support-url: https://access.redhat.com
21 | template.openshift.io/bindable: 'false'
22 | aerogear.org/datasync-template-version: '0.9.3'
23 | objects:
24 |
25 | - apiVersion: v1
26 | kind: DeploymentConfig
27 | metadata:
28 | annotations:
29 | openshift.io/generated-by: OpenShiftNewApp
30 | creationTimestamp: null
31 | labels:
32 | app: datasync-starter-server
33 | name: datasync-starter-server
34 | spec:
35 | replicas: 1
36 | selector:
37 | app: datasync-starter-server
38 | deploymentconfig: datasync-starter-server
39 | strategy:
40 | type: Recreate
41 | template:
42 | metadata:
43 | annotations:
44 | openshift.io/generated-by: OpenShiftNewApp
45 | creationTimestamp: null
46 | labels:
47 | app: datasync-starter-server
48 | deploymentconfig: datasync-starter-server
49 | spec:
50 | containers:
51 | - env:
52 | - name: MONGO_HOST
53 | value: "${DATABASE_HOST}"
54 | - name: MONGO_USER
55 | value: "${DATABASE_USER}"
56 | - name: MONGO_PASSWORD
57 | value: "${DATABASE_PASSWORD}"
58 | - name: MQTT_HOST
59 | value: mosquitto-mqtt-broker
60 | image: docker.io/aerogear/datasync-starter:latest
61 | name: datasync-starter-server
62 | ports:
63 | - containerPort: 4000
64 | protocol: TCP
65 | resources: {}
66 | triggers:
67 | - type: ConfigChange
68 |
69 | - apiVersion: v1
70 | kind: Service
71 | metadata:
72 | annotations:
73 | openshift.io/generated-by: OpenShiftNewApp
74 | creationTimestamp: null
75 | labels:
76 | app: datasync-starter-server
77 | name: datasync-starter-server
78 | spec:
79 | ports:
80 | - name: 4000-tcp
81 | port: 4000
82 | protocol: TCP
83 | targetPort: 4000
84 | selector:
85 | app: datasync-starter-server
86 | deploymentconfig: datasync-starter-server
87 |
88 |
89 | - apiVersion: v1
90 | kind: Route
91 | metadata:
92 | labels:
93 | app: datasync-starter-server
94 | name: "${SERVER_SERVICE_NAME}"
95 | spec:
96 | host: ""
97 | to:
98 | kind: Service
99 | name: "${SERVER_SERVICE_NAME}"
100 | tls:
101 | termination: edge
102 | insecureEdgeTerminationPolicy: Allow
103 |
104 | - kind: DeploymentConfig
105 | apiVersion: v1
106 | name: mosquitto-mqtt-broker
107 | metadata:
108 | annotations:
109 | openshift.io/generated-by: OpenShiftNewApp
110 | creationTimestamp: null
111 | labels:
112 | app: datasync-starter-server
113 | name: mosquitto-mqtt-broker
114 | spec:
115 | replicas: 1
116 | selector:
117 | app: datasync-starter-server
118 | deploymentconfig: mosquitto-mqtt-broker
119 | template:
120 | metadata:
121 | annotations:
122 | openshift.io/generated-by: OpenShiftNewApp
123 | creationTimestamp: null
124 | labels:
125 | app: datasync-starter-server
126 | deploymentconfig: mosquitto-mqtt-broker
127 | spec:
128 | containers:
129 | - name: mosquitto-mqtt-broker
130 | image: eclipse-mosquitto:latest
131 | ports:
132 | - containerPort: 1883
133 | protocol: TCP
134 | strategy:
135 | resources: {}
136 | type: Rolling
137 | paused: false
138 | revisionHistoryLimit: 2
139 | minReadySeconds: 0
140 |
141 | - apiVersion: v1
142 | kind: Service
143 | metadata:
144 | annotations:
145 | openshift.io/generated-by: OpenShiftNewApp
146 | creationTimestamp: null
147 | labels:
148 | app: datasync-starter-server
149 | name: mosquitto-mqtt-broker
150 | spec:
151 | ports:
152 | - name: 1883-tcp
153 | port: 1883
154 | protocol: TCP
155 | targetPort: 1883
156 | selector:
157 | app: datasync-starter-server
158 | deploymentconfig: mosquitto-mqtt-broker
159 | status:
160 | loadBalancer: {}
161 |
162 | parameters:
163 | - description: Maximum amount of memory the container can use.
164 | displayName: Memory Limit
165 | name: MEMORY_LIMIT
166 | value: 512Mi
167 | - description: The OpenShift Namespace where the ImageStream resides.
168 | displayName: Namespace
169 | name: NAMESPACE
170 | value: openshift
171 | - description: The name of the OpenShift Service exposed for the database.
172 | displayName: Database Service Name
173 | name: DATABASE_HOST
174 | value: mongodb
175 | - description: Username for MongoDB user that will be used for accessing the database.
176 | displayName: Mongo Connection Username
177 | name: DATABASE_USER
178 | value:
179 | - description: Password for the MongoDB connection user.
180 | displayName: MongoDB Connection Password
181 | name: DATABASE_PASSWORD
182 | value: postgres
183 | - description: The name of the OpenShift Service exposed for the Server.
184 | displayName: Server Service name
185 | name: SERVER_SERVICE_NAME
186 | value: datasync-starter-server
187 |
--------------------------------------------------------------------------------
/README.adoc:
--------------------------------------------------------------------------------
1 | = AeroGear DataSync Starter
2 |
3 | AeroGear DataSync GraphQL based server and React Client
4 |
5 | == DataSync Starter
6 |
7 | Starter includes:
8 |
9 | - Integration with Graphback (http://graphback.dev) that helps you to generate your backend and client side queries based on user provided business model
10 | - Example React application that uses Offix client (http://offix.dev) to give you fully featured offline experience.
11 | - Integration with Keycloak (SSO) for server and client Authentication and User management.
12 | - GraphQL Subscriptions backed by an MQTT broker (AMQ)
13 |
14 | === GraphQL Client
15 |
16 | The mobile application uses https://github.com/aerogear/offix[Offix Client] to provide additional offline capabilities on top of Apollo GraphQL.
17 |
18 | === GraphQL Server
19 |
20 | The GraphQL server uses https://github.com/aerogear/graphback[Graphback] to provide GraphQL capabilities.
21 |
22 | == Getting Started
23 |
24 | Requirements:
25 |
26 | - Docker and docker-compose
27 | - Node.js 12.x or above
28 | - (optional) access to a running OpenShift instance
29 |
30 | === Running app and server
31 |
32 | Install dependencies
33 |
34 | . Build client
35 |
36 | +
37 | ```shell
38 | yarn
39 | ```
40 | +
41 |
42 | . Build client
43 | +
44 | ```shell
45 | yarn
46 | yarn prepare:client
47 | ```
48 | +
49 |
50 | . Start the server
51 | +
52 | ```shell
53 | cd ./server
54 | docker-compose up -d
55 | yarn start
56 | ```
57 |
58 |
59 | === Running the Server with the Keycloak integration
60 |
61 | Follow these instructions to set up Keycloak for Authentication/Authorization.
62 |
63 |
64 | . Start Keycloak Server
65 | +
66 | ```shell
67 | cd server
68 | npm run keycloak
69 | ```
70 |
71 | . Configure the Keycloak Server
72 | +
73 | ```shell
74 | cd server
75 | npm run keycloak:init
76 | ```
77 |
78 | This command creates the necessary resources in Keycloak and prints instructions *you must follow to enable the integration.*
79 |
80 | Follow the instructions and copy the JSON configurations to the appropriate locations.
81 | The DataSync Starter app and server will read these configurations and the integration will be enabled when they are started.
82 |
83 | By default, two users that can log into the application are created.
84 |
85 | - username: `developer`, password: `developer`
86 | - username: `admin`, password: `admin`
87 |
88 | ==== Using the GraphQL playground with Keycloak
89 |
90 | The GraphQL playground is available after a Keycloak login screen. Initially the following error will be displayed.
91 |
92 | ```
93 | {
94 | "error": "Failed to fetch schema. Please check your connection"
95 | }
96 | ```
97 |
98 | The playground must be configured to send the Keycloak `Authorization` header with requests to the GraphQL server.
99 |
100 | In the bottom left corner of the playground there is a field called **HTTP Headers** which will be added to requests sent by the playground.
101 |
102 | Use `scripts/getToken.js` to get a valid header for the `developer` user.
103 |
104 | The following script can be used to get a token for the default Keycloak credentials
105 |
106 | ```
107 | cd server/scripts/keycloak
108 | node getToken.js
109 | ```
110 |
111 | Alternatively, the user-defined username and password can be passed into the script as arguments, as below
112 |
113 | ```
114 | node getToken.js
115 | ```
116 |
117 | The output will be in the form of a JSON object
118 |
119 | ```
120 | {"Authorization":"Bearer "}
121 | ```
122 |
123 | Copy the entire JSON object, then paste it into the HTTP Headers field in the playground.
124 | The error message should disappear and it is now possible to use the playground.
125 |
126 | NOTE: The GraphQL server is using a `public` Keycloak client to redirect browsers to the login page. This is useful for testing the server locally but **it is not recommended for production**. For production GraphQL server applications you should use a `bearer` client.
127 |
128 | [NOTE]
129 | ====
130 | If Keycloak integration is enabled on the server, and the Keycloak server is running using a self-signed certificate, please make sure set this environment variable before running the server:
131 |
132 | ```shell
133 | export NODE_TLS_REJECT_UNAUTHORIZED=0
134 | ```
135 | ====
136 |
137 |
138 | === Running the Client
139 |
140 | . Install Ionic
141 | +
142 | ```shell
143 | npm install -g @ionic/cli
144 | ```
145 |
146 | . Change directory
147 |
148 | +
149 | ```shell
150 | cd client
151 | ```
152 | +
153 |
154 | . Install dependencies
155 | +
156 | ```shell
157 | npm install
158 | ```
159 | +
160 | . Start the app
161 | +
162 | ```shell
163 | npm run start
164 | ```
165 | +
166 |
167 |
168 | === Adding keycloak integration to the client
169 |
170 | Rename `keycloak.example.json` file in the `public` directory to `keycloak.json`. Replace the contents of the file
171 | with the keycloak json object generated during the keycloak integration init script.
172 |
173 | [source,js]
174 | ----
175 | {
176 | "realm": "",
177 | "auth-server-url": "https://your-server/auth",
178 | "ssl-required": "none",
179 | "resource": "",
180 | "public-client": true,
181 | "use-resource-role-mappings": true,
182 | "confidential-port": 0
183 | }
184 | ----
185 |
186 | > NOTE: When running in cloud, developers can swap this file dynamically using config-map or openshift secret
187 |
188 | === Running Native projects
189 |
190 | ==== IOS
191 | -----
192 | cd client
193 | yarn cap add ios
194 | yarn run:ios
195 | -----
196 |
197 | ==== Android:
198 | -----
199 | cd client
200 | yarn cap add android
201 | yarn run:android
202 | -----
203 |
204 | When running locally you will need to also enable http traffic.
205 | For example for android add `android:usesCleartextTraffic="true"` to AndroidManifest.xml
206 |
207 | Project should stard in IDE and can be launched as any other native application
208 |
209 | == Using MQTT for GraphQL subscriptions
210 |
211 | 1. Go to scripts ./mqtt
212 | 2. Execute docker-compose up
213 | 3. Set MQTT_HOST environment variable in .env file
214 |
215 | MQTT_HOST=127.0.0.1
216 |
217 | === Running On OpenShift
218 |
219 | Please check link:./openshift[.openshift] folder for more information.
220 |
--------------------------------------------------------------------------------
/server/integrations/keycloak/initKeycloak.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This script helps set up a local keycloak environment
3 | * It assumes keycloak is already running and available at
4 | * http://localhost:8080
5 | * It does the following:
6 | * * Imports a new realm located in ./realm-export.json. This realm has two clients
7 | * * datasync-starter-client- public client used by the mobile application
8 | * * datasync-starter-server - bearer client used by the server to authenticate requests
9 | * * Creates the realm roles and client roles defined in the realmRoleNames and clientRoleNames lists
10 | * * Creates the users defined in the users list
11 | * * Assigns the client and realm roles to the users
12 | */
13 |
14 | const axios = require("axios");
15 | const realmToImport = require("./realm-export.json");
16 | const { fstat, writeFileSync } = require("fs");
17 |
18 | // the keycloak server we're working against
19 | const KEYCLOAK_URL = process.env.KEYCLOAK_URL || "http://localhost:8080/auth";
20 |
21 | // name of the realm
22 | const APP_REALM = process.env.KEYCLOAK_REALM || "datasync-starter";
23 |
24 | // name of the admin realm
25 | const ADMIN_REALM = "master";
26 |
27 | const RESOURCE = "admin-cli";
28 | const ADMIN_USERNAME = process.env.KEYCLOAK_ADMIN_USERNAME || "admin";
29 | const ADMIN_PASSWORD = process.env.KEYCLOAK_ADMIN_PASSWORD || "admin";
30 | let token = "";
31 |
32 | // The keycloak client used by the sample app
33 | const PUBLIC_CLIENT_NAME = "datasync-starter-client";
34 | const BEARER_CLIENT_NAME = "datasync-starter-server";
35 | let PUBLIC_CLIENT;
36 |
37 | // The client roles you want created for the BEARER_CLIENT_NAME client
38 | const clientRoleNames = ["admin", "developer"];
39 |
40 | // The realm roles we want for the realm
41 | const realmRoleNames = ["admin", "developer"];
42 |
43 | let realmRoles;
44 |
45 | // The users we want to create
46 | const users = [
47 | {
48 | name: "admin",
49 | password: "admin",
50 | realmRoles: ["admin"],
51 | clientRoles: ["admin"],
52 | },
53 | {
54 | name: "developer",
55 | password: "developer",
56 | realmRoles: ["developer"],
57 | clientRoles: ["developer"],
58 | },
59 | ];
60 |
61 | const writeConfig = false;
62 |
63 | // This is called by an immediately invoked function expression
64 | // at the bottom of the file
65 | async function prepareKeycloak() {
66 | try {
67 | console.log("Authenticating with keycloak server");
68 | token = await authenticateKeycloak();
69 |
70 | // Always do a hard reset first just to keep things tidy
71 | console.log("Going to reset keycloak");
72 | await resetKeycloakConfiguration();
73 |
74 | console.log("Importing sample realm into keycloak");
75 | await importRealm();
76 |
77 | console.log("Fetching available clients from keycloak");
78 | const clients = await getClients();
79 |
80 | // Get the public client object from keycloak
81 | // Need this for the ID assigned by keycloak
82 | PUBLIC_CLIENT = clients.find(
83 | (client) => client.clientId === PUBLIC_CLIENT_NAME
84 | );
85 | BEARER_CLIENT = clients.find(
86 | (client) => client.clientId === BEARER_CLIENT_NAME
87 | );
88 |
89 | console.log("creating client roles");
90 | for (let roleName of clientRoleNames) {
91 | await createClientRole(BEARER_CLIENT, roleName);
92 | await createClientRole(PUBLIC_CLIENT, roleName);
93 | }
94 |
95 | console.log("creating realm roles");
96 | for (let roleName of realmRoleNames) {
97 | await createRealmRole(roleName);
98 | }
99 |
100 | // get the actual role objects from keycloak after creating them
101 | // need to get the ids that were created on them
102 | realmRoles = await getRealmRoles();
103 | bearerClientRoles = await getClientRoles(BEARER_CLIENT);
104 | publicClientRoles = await getClientRoles(PUBLIC_CLIENT);
105 |
106 | for (let user of users) {
107 | // Create a new user
108 | console.log(`creating user ${user.name} with password ${user.password}`);
109 | const userIdUrl = await createUser(user.name, user.password);
110 |
111 | // Assign roles to the user
112 | await assignRealmRolesToUser(user, userIdUrl);
113 | await assignClientRolesToUser(
114 | user,
115 | BEARER_CLIENT,
116 | bearerClientRoles,
117 | userIdUrl
118 | );
119 | await assignClientRolesToUser(
120 | user,
121 | PUBLIC_CLIENT,
122 | publicClientRoles,
123 | userIdUrl
124 | );
125 | }
126 |
127 | const publicInstallation = await getClientInstallation(PUBLIC_CLIENT);
128 |
129 | const bearerInstallation = await getClientInstallation(BEARER_CLIENT);
130 | if (writeConfig) {
131 | writeFileSync(
132 | `../client/public/keycloak.json`,
133 | JSON.stringify(publicInstallation, null, 2)
134 | );
135 | writeFileSync(
136 | `../server/src/config/keycloak.json`,
137 | JSON.stringify(bearerInstallation, null, 2)
138 | );
139 | }
140 | console.log();
141 | console.log(
142 | "Your keycloak server is set up for local usage and development"
143 | );
144 | console.log();
145 | console.log("Copy the following app config into the following files:");
146 | console.log("- client/public/keycloak.json");
147 | console.log();
148 | console.log(JSON.stringify(publicInstallation, null, 2));
149 | console.log();
150 | console.log("- server/src/config/keycloak.json");
151 | console.log();
152 | console.log(JSON.stringify(bearerInstallation, null, 2));
153 | console.log();
154 | console.log(
155 | "Done. Please follow the instructions printed above to ensure your environment is set up properly."
156 | );
157 | } catch (e) {
158 | console.error(e);
159 | process.exit(1);
160 | }
161 | }
162 |
163 | async function getClientInstallation(
164 | client,
165 | installationType = "keycloak-oidc-keycloak-json"
166 | ) {
167 | if (client) {
168 | const res = await axios({
169 | method: "GET",
170 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/clients/${client.id}/installation/providers/${installationType}`,
171 | headers: { Authorization: token },
172 | });
173 | return res.data;
174 | }
175 | throw new Error("client is undefined");
176 | }
177 |
178 | async function assignRealmRolesToUser(user, userIdUrl) {
179 | for (let roleToAssign of user.realmRoles) {
180 | console.log(`Assigning realm role ${roleToAssign} to user ${user.name}`);
181 | const selectedRealmRole = realmRoles.find(
182 | (role) => role.name === roleToAssign
183 | );
184 |
185 | if (selectedRealmRole) {
186 | await assignRealmRoleToUser(userIdUrl, selectedRealmRole);
187 | } else {
188 | console.error(`realm role ${roleToAssign} does not exist`);
189 | }
190 | }
191 | }
192 |
193 | async function assignClientRolesToUser(user, client, clientRoles, userIdUrl) {
194 | for (let roleToAssign of user.clientRoles) {
195 | console.log(
196 | `assigning client role ${roleToAssign} from client ${client.clientId} on user ${user.name}`
197 | );
198 | const selectedClientRole = clientRoles.find(
199 | (clientRole) => clientRole.name === roleToAssign
200 | );
201 | if (selectedClientRole) {
202 | await assignClientRoleToUser(userIdUrl, client, selectedClientRole);
203 | } else {
204 | console.error(
205 | `client role ${roleToAssign} does not exist on client ${client.clientId}`
206 | );
207 | }
208 | }
209 | }
210 |
211 | async function authenticateKeycloak() {
212 | console.log(
213 | `client_id=${RESOURCE}&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}&grant_type=password`
214 | );
215 | const res = await axios({
216 | method: "POST",
217 | url: `${KEYCLOAK_URL}/realms/${ADMIN_REALM}/protocol/openid-connect/token`,
218 | data: `client_id=${RESOURCE}&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}&grant_type=password`,
219 | });
220 | return `Bearer ${res.data["access_token"]}`;
221 | }
222 |
223 | async function importRealm() {
224 | return await axios({
225 | method: "POST",
226 | url: `${KEYCLOAK_URL}/admin/realms`,
227 | data: realmToImport,
228 | headers: { Authorization: token, "Content-Type": "application/json" },
229 | });
230 | }
231 |
232 | async function getRealmRoles() {
233 | const res = await axios({
234 | method: "GET",
235 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/roles`,
236 | headers: { Authorization: token },
237 | });
238 | return res.data;
239 | }
240 |
241 | async function createClientRole(client, roleName) {
242 | try {
243 | return await axios({
244 | method: "POST",
245 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/clients/${client.id}/roles`,
246 | headers: { Authorization: token },
247 | data: {
248 | clientRole: true,
249 | name: roleName,
250 | },
251 | });
252 | } catch (e) {
253 | if (
254 | e.response.data.errorMessage ===
255 | `Role with name ${roleName} already exists`
256 | ) {
257 | console.log(e.response.data.errorMessage);
258 | } else {
259 | throw e;
260 | }
261 | }
262 | }
263 |
264 | async function createRealmRole(roleName) {
265 | try {
266 | return await axios({
267 | method: "POST",
268 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/roles`,
269 | headers: { Authorization: token },
270 | data: {
271 | clientRole: false,
272 | name: roleName,
273 | },
274 | });
275 | } catch (e) {
276 | if (
277 | e.response.data.errorMessage ===
278 | `Role with name ${roleName} already exists`
279 | ) {
280 | console.log(e.response.data.errorMessage);
281 | } else {
282 | throw e;
283 | }
284 | }
285 | }
286 |
287 | async function getClients() {
288 | const res = await axios({
289 | method: "GET",
290 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/clients`,
291 | headers: { Authorization: token },
292 | });
293 | return res.data;
294 | }
295 |
296 | async function getClientRoles(client) {
297 | const res = await axios({
298 | method: "GET",
299 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/clients/${client.id}/roles`,
300 | headers: { Authorization: token },
301 | });
302 | return res.data;
303 | }
304 |
305 | async function createUser(name, password) {
306 | const res = await axios({
307 | method: "post",
308 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}/users`,
309 | data: {
310 | username: name,
311 | credentials: [{ type: "password", value: password, temporary: false }],
312 | enabled: true,
313 | },
314 | headers: { Authorization: token, "Content-Type": "application/json" },
315 | });
316 | if (res) {
317 | return res.headers.location;
318 | }
319 | }
320 |
321 | async function assignRealmRoleToUser(userIdUrl, role) {
322 | const res = await axios({
323 | method: "POST",
324 | url: `${userIdUrl}/role-mappings/realm`,
325 | data: [role],
326 | headers: { Authorization: token, "Content-Type": "application/json" },
327 | });
328 | return res.data;
329 | }
330 |
331 | async function assignClientRoleToUser(userIdUrl, client, role) {
332 | const res = await axios({
333 | method: "POST",
334 | url: `${userIdUrl}/role-mappings/clients/${client.id}`,
335 | data: [role],
336 | headers: { Authorization: token, "Content-Type": "application/json" },
337 | });
338 | return res.data;
339 | }
340 |
341 | async function resetKeycloakConfiguration() {
342 | try {
343 | await axios({
344 | method: "DELETE",
345 | url: `${KEYCLOAK_URL}/admin/realms/${APP_REALM}`,
346 | headers: { Authorization: token },
347 | });
348 | } catch (e) {
349 | if (e.response.status !== 404) {
350 | throw e;
351 | }
352 | console.log(`404 while deleting realm ${APP_REALM} - ignoring`);
353 | }
354 | }
355 |
356 | function getMobileServicesConfig(installationConfig) {
357 | return {
358 | id: "some-id",
359 | name: "keycloak",
360 | type: "keycloak",
361 | url: KEYCLOAK_URL,
362 | config: installationConfig,
363 | };
364 | }
365 |
366 | (async () => {
367 | await prepareKeycloak();
368 | })();
369 |
--------------------------------------------------------------------------------
/walkthroughs/1-exploring-datasync-codeready/walkthrough.adoc:
--------------------------------------------------------------------------------
1 | // update the component versions for each release
2 | :rhmi-version: 1
3 |
4 | // URLs
5 | :openshift-console-url: {openshift-host}/console
6 | :sso-realm-url: {user-sso-url}/auth/admin/solution-patterns/console/index.html
7 | :data-sync-documentation-url: https://access.redhat.com/documentation/en-us/red_hat_managed_integration/{rhmi-version}/html-single/developing_a_data_sync_app/index
8 |
9 | //attributes
10 | :integreatly-name: Managed Integration
11 | :data-sync-name: Data Sync
12 | :data-sync-starter: Data Sync Starter
13 | :customer-sso-name: SSO
14 | :standard-fail-text: Verify that you followed all the steps. If you continue to have issues, contact your administrator.
15 |
16 | //id syntax is used here for the custom IDs because that is how the Solution Explorer sorts these within groups
17 | [id='5-adding-data-sync-graphql']
18 | = Creating your Data Sync Application in CodeReady
19 |
20 | // word count that fits best is 15-22, with 20 really being the sweet spot. Character count for that space would be 100-125
21 | Learn how to build applications that can perform realtime data synchronization with DataSync and GraphQL.
22 |
23 | This solution pattern will show you how to:
24 |
25 | * Build a DataSync server based on your business model
26 | * Protect the application's frontend and backend using {customer-sso-name}.
27 | * Explore all the capabilities provided by {data-sync-name}.
28 |
29 | The following diagram shows the architecture of the {data-sync-starter}:
30 |
31 | image::images/arch.png[architecture, role="integr8ly-img-responsive"]
32 |
33 | [type=walkthroughResource, serviceName=openshift]
34 | .Red Hat OpenShift
35 | ****
36 | * link:{openshift-console-url}[Console, window="_blank"]
37 | * link:https://docs.openshift.com/dedicated/4/welcome/index.html[OpenShift Documentation, window="_blank"]
38 | * link:https://blog.openshift.com/[OpenShift Blog, window="_blank"]
39 | ****
40 |
41 | [type=walkthroughResource]
42 | .Data Sync
43 | ****
44 | * link:{data-sync-documentation-url}[Getting Started with {data-sync-name}, window="_blank"]
45 | ****
46 |
47 | :sectnums:
48 |
49 | [time=15]
50 | == Creating your DataSync project using DataSync Starter template
51 |
52 | {data-sync-name} allows you to focus on your business model by giving you the ability
53 | to generate a fully functional GraphQL based API and Node.js Server and Client Side components.
54 |
55 | Developers can use GraphQL types to define the models their application deals with
56 | and generate the underlying backend that works out of the box with the RHMI services like SSO or AMQ Online.
57 |
58 | Using GraphQL subscriptions, your application can receive live updates thanks to GraphQL and the DataSync client allows your application to operate independently of the network connection.
59 |
60 | DataSync-Starter is an application template that gives developers
61 | opiniated implementation for React Ionic PWA and Mobile application backend by Node.js Server.
62 |
63 | DataSync as framework consist of the two major components:
64 |
65 | * DataSync client (upstream https://offix.dev)
66 | +
67 | Can be added to any web or mobile application
68 | to provide DataSynchronization capabilities
69 |
70 | * Node.js Server with code generation capabilties (upstream https://graphback.dev)
71 | +
72 | Server is providing ability to dynamically generate Node.js GraphQL server with DataSynchronization and Conflict resolution capabilties
73 |
74 | [time=30]
75 | === Setting your DataSync project using in CodeReady
76 |
77 | In this section we will use CodeReady Workspaces as a development environment to work with the DataSync Starter project.
78 |
79 | . Navigate to the solution explorer
80 | . Go to the "All Services" tab
81 | . Go to "Online IDE" and select "Open Console"
82 | . Login to the CodeReady Workspaces dashboard
83 | . Choose `NodeJS MongoDB Web Application` from list of the available stacks
84 | . Enter `https://github.com/aerogear/datasync-starter#walkthrough` for the repository URL
85 | . In the right corner press down arrow and select `Create and Start Editing`
86 | . In the new window select the `Dev File` tab and the copy dev file from
87 | https://raw.githubusercontent.com/aerogear/datasync-starter/master/devfile.yaml
88 | . Press `Create`
89 | . Wait for the workspace to start
90 | . Press `Open` to open CodeReady editor
91 | . In the active workspace, run the `Install dependencies` command.
92 | +
93 | The commands are listed in the `MyWorkspace` view available by selecting the box icon in the right top corner of the screen.
94 |
95 | === Creating your DataSync model
96 |
97 | Based on a user defined **Model** DataSync generates the underlying GraphQL API and database resources.
98 | The starter template uses MongoDB as the default datasource for storing application data.
99 |
100 | . In your project explorer view on the left, go to the `./model/task.graphql` file.
101 | This file contains an link:https://graphql.org/learn/schema/#object-types-and-fields[GraphQL Schema, window="_blank"]. Here you can define types of the data your application will work with. Graphback will use your model and generate underlying data access methods in the form of GraphQL Queries and Mutations
102 | . Please add a `@datasync` annotation under the `@model` annotation on the `Task` type.
103 | Those annotations control various behaviour of DataSync.
104 | `@model` will create standard data access methods, while `@datasync` will provide data synchronization capabilities.
105 | . Models can be changed by adding new types or fields. Add a new field to the `Task` type by adding `address: String`
106 | . Your task model should be as follows
107 | ----
108 | """
109 | @model
110 | @datasync
111 | """
112 | type Task {
113 | id: ID!
114 | title: String!
115 | description: String!
116 | status: TaskStatus
117 | address: String
118 | }
119 | ----
120 |
121 | === Generating your DataSync Node.JS server and React App
122 |
123 | DataSync provides code generation capabilities that transform your model into a fully functional client and server application.
124 | The datasync-starter template contains the following folders:
125 |
126 | * `./server`- contains a Node.js server application written in TypeScript. `server/src/index.ts` is the entrypoint to the server application.
127 | * `./client` - contains a React application written in TypeScript. `client/src/index.tsx` is the entrypoint to the client application.
128 | * `.graphqlrc.yml` -
129 |
130 | . Review the `.graphqlrc.yml` file. This is a config file that
131 | ** determines what types of CRUD operations will be generated into GraphQL Queries and Mutations in your application.
132 | ** enables and configures various plugins that are used during the code generation process.
133 | . Make sure that all fields in the `crud` section are enabled
134 | . Execute graphback cli command to generate source code:
135 | `yarn graphback generate`. You can also execute it as predefined `generate source code` command in CodeReady
136 | . Review `./server/src/schema/schema.qraphql`.
137 | This file has the original model and it also contains generated Queries and Mutations. The types of queries and mutations included are based on the `crud` fields in `.graphqlrc.yml`.
138 | . Review the generated resolver files in `./server/src/resolvers/resolvers.ts`
139 | This file contains methods used to fetch and modify data. Each individual method uses a
140 | preconfigured `MongoDataProvider` which is an abstraction over a MongoDB client. Developers can point resolvers to any datasource.
141 | Currently Postgres and MongoDB are supported.
142 | . Review your `./client/src/graphql/` folder containing client side queries for your data. These queries are automatically generated based on the server schema, and are used within the client application. Generating the client side queries helps developers get their client apps up and running quicker and helps them stay up to date as the server schema evolves.
143 |
144 | === Running DataSync client and server applications
145 |
146 | . Open a new terminal window
147 | . Execute the `prepare client` command in the new terminal. Client side application will be build and started. This can take a couple of minutes.
148 | . Execute the `start server` command. This command starts the GraphQL server which also serves the client application for simplicity.
149 | . The application should be opened in a preview window after build is finished.
150 |
151 | [type=verification]
152 | ****
153 | . Check if the website was loaded properly
154 | . Select the + icon to create a new item
155 | . On the new screen enter a `name` and `description` and create the task.
156 | . New task should appear in the task list.
157 | ----
158 | ****
159 |
160 | [type=verificationFail]
161 | ****
162 | Check the logs of the console
163 | Verify that you followed each step in the procedure above.
164 | If you are still having issues, contact your administrator.
165 | ****
166 |
167 | === Interacting with the GraphQL Playground
168 |
169 | The GraphQL Playground is an in browser GraphQL IDE that lets you directly perform queries and mutations against your GraphQL API.
170 | It's a convenient way to interact with your GraphQL API without using a client application.
171 | It is served directly by your server application as a developer tool and can be disabled in production.
172 | In this section we will focus on using the playground.
173 |
174 | . Open a new terminal window
175 | . Execute `yarn start:server`
176 | . Open the GraphQL Playground URL printed in console.
177 | You can use the GraphQL playground to interact with the server API as described in the next step.
178 | . Go to the Playground interface and replace the text in the left pane of the screen with the following query and mutation:
179 |
180 | ----
181 | query listTasks {
182 | findAllTasks {
183 | title,
184 | description,
185 | address,
186 | id
187 | }
188 | }
189 |
190 | mutation createTask {
191 | createTask(input: {title: "complete the walkthrough", description: "complete the GraphQL walkthrough", address: "NA"}) {
192 | title,
193 | description,
194 | version,
195 | address,
196 | id
197 | }
198 | }
199 | ----
200 |
201 | [type=verification]
202 | ****
203 | . Click the Run icon in the middle of the playground screen.
204 | . Choose `createTask` from the menu.
205 | The system should create a task and the result is displayed in the panel on the right side.
206 | . Choose `listTasks` from the Run menu.
207 | . Check that the following is displayed in the right hand panel:
208 | . You should also see the `address` field that we have added in previous steps.
209 | +
210 | ----
211 | {
212 | "data": {
213 | "allTasks": [
214 | {
215 | "title": "complete the walkthrough",
216 | "description": "complete the GraphQL walkthrough",
217 | "id": "1",
218 | "address": "NA"
219 | }
220 | ]
221 | }
222 | }
223 | ----
224 | ****
225 |
226 | [type=verificationFail]
227 | ****
228 | Check the logs of the `ionic-showcase-server` pod.
229 |
230 | It should include the string `+connected to messaging service+`.
231 | Verify that you followed each step in the procedure above. If you are still having issues, contact your administrator.
232 | ****
233 |
234 | [time=5]
235 | == Running and verifying your DataSync server
236 |
237 | The {data-sync-starter} provides:
238 |
239 | - Offline operation support
240 | - Realtime updates through GraphQL Subscriptions
241 | - Conflict detection and resolution
242 |
243 | In this guide we will explore the capabilities of DataSync by using the
244 | generated server application and the sample frontend application available as part of {data-sync-starter}.
245 | The frontend application is a Todo style app that uses the `Task` model.
246 |
247 | . Go back to the application opened in the previous step.
248 | . Create a task by clicking on the plus icon in the bottom right-hand side of the screen.
249 | . Add a title and description, of your choosing, to the task and click *Create*.
250 | . Copy the current url and paste it in a different tab, browser or mobile browser.
251 | . Change the status of the task by clicking/unclicking the text box beside the task.
252 |
253 |
254 | [type=verification]
255 | ****
256 | Verify that the status of the task is synced across all tabs in real-time.
257 | ****
258 |
259 | [type=verificationFail]
260 | ****
261 | Verify that you followed each step in the procedure above. If you are still having issues, contact your administrator.
262 | ****
263 |
264 | [time=10]
265 | == Exploring data sync features using the Data Sync showcase application
266 |
267 | To explore data sync features, you should run multiple instances of the {data-sync-starter} using different browsers.
268 | For example, use the browser on your mobile device as well as using the browser on your laptop.
269 |
270 | === Exploring real-time sync
271 |
272 | . On your laptop:
273 | .. Create a new task using *+* icon.
274 | .. Enter some task text and click *Create*.
275 |
276 | . On your second device:
277 | .. Check that the same task appears in the tasks page
278 | .. Make some changes to the task.
279 |
280 | . On your laptop:
281 | .. Check that the task changes are synchronized.
282 |
283 |
284 | [type=verification]
285 | ****
286 | Did the tasks appear as expected?
287 | ****
288 |
289 | [type=verificationFail]
290 | ****
291 | Verify that you followed each step in the procedure above. If you are still having issues, contact your administrator.
292 | ****
293 |
294 | === Exploring offline support
295 |
296 | DataSync provides offline and conflict resolution for client side applications
297 | like React, Angular or Vue. Sample application implements `Task` model
298 | generated from server and utilizes Offix (http://offix.dev) client to enable
299 | offline and conflict capabilities.
300 |
301 | . On your mobile device:
302 | .. Activate airplane mode or disable network connectivity.
303 | .. Create a new task.
304 | The task should be created and the *Offline Changes* button in the footer should contain one change.
305 | .. Make a few more changes by either editing existing tasks, or creating new ones.
306 | .. Review all the changes by clicking the *Offline Changes* button.
307 |
308 | . On your laptop:
309 | You do not see any of the changes from the mobile device.
310 |
311 | . On your second device:
312 | .. Restore connectivity or deactivate airplane mode.
313 | .. Watch the status of the tasks change.
314 |
315 | . On your laptop:
316 | .. Check that all the tasks are synchronized.
317 |
318 |
319 | [type=verification]
320 | ****
321 | Did the tasks appear as expected?
322 | ****
323 |
324 | [type=verificationFail]
325 | ****
326 | Verify that you followed each step in the procedure above. If you are still having issues, contact your administrator.
327 | ****
328 |
329 | === Resolving conflicts
330 |
331 | . On your second device:
332 | .. Create a task `todo A`.
333 | .. Activate airplane mode or disable network connectivity.
334 | .. Edit the task description to add the text `edited on mobile`.
335 |
336 | . On your laptop:
337 | .. Simulate offline mode. For example, in Chrome, press F12 to open *Developer Tools* and select *offline* in the *Network* tab.
338 | .. Edit the `todo A` task, change the text to `todo B`.
339 |
340 | . Bring both of your devices back online, the tasks should sync without a conflict.
341 |
342 | . On your mobile device:
343 | .. Activate airplane mode or disable network connectivity.
344 | .. Edit task `todo B` change the description to:
345 | +
346 | ----
347 | Conflicting description from mobile
348 | ----
349 |
350 | . On your laptop:
351 | .. Simulate offline mode. For example, in Chrome, press F12 to open *Developer Tools* and select *offline* in the *Network* tab.
352 | .. Edit task `todo B` change the description to:
353 | +
354 | ----
355 | Conflicting description from laptop
356 | ----
357 |
358 | . Bring both of your devices back online, a popup window should appear warning you about conflicts.
359 |
360 | [type=verification]
361 | ****
362 | Did the tasks sync as expected?
363 | ****
364 |
365 | [type=verificationFail]
366 | ****
367 | Verify that you followed each step in the procedure above. If you are still having issues, contact your administrator.
368 | ****
369 |
370 | . Close terminal window running server application
371 |
372 | [time=15]
373 | == Add authentication and authorization to the Data Sync application using Red Hat SSO
374 |
375 | In this section, we will configure both the frontend and the backend of the
376 | {data-sync-starter} with the {customer-sso-name}.
377 |
378 | DataSync starter has authentication and autorization enabled out of the box.
379 | Developers need to configure server and client application to use their keycloak instance
380 | and add required authorization rules to their model.
381 |
382 | == Add authorization rule for Task deletion
383 |
384 | . Go to your GraphQL Schema `./server/src/config/auth.ts`.
385 | This file contains auth rules for all the operations we support.
386 | . Change role from `delete: { roles: ['admin'] }` to delete: { roles: ['test'] },
387 | This will only allow deletion for test role that we haven't created.
388 | This operation will prevent us from deleting items from the list.
389 |
390 | === Configuring Authentication for Keycloak (SSO)
391 |
392 | . In solution explorer open the User SSO service.
393 | . Login using your own credentials (You might need to open this tab in incognito mode).
394 | . In menu on the left hover over realm name.
395 | . Select `Add new realm`
396 | . Put `DataSync Example` as name and press `Create`
397 | . Select *Clients* from the vertical navigation menu on the left side of the screen.
398 | . Click the *Create* button on the top right of the Clients screen.
399 | . On the *Add Client* screen:
400 | .. In the *Client ID* field, enter
401 | +
402 | [subs="attributes+"]
403 | ----
404 | public-datasync
405 | ----
406 | .. Verify the *Client Protocol* is set to *openid-connect*.
407 | .. Click *Save*.
408 | . You will see the *Settings* screen for the *{client-name}* client if the save is successful.
409 | . on the *Settings* page:
410 | .. Change `Valid Redirect URIs` to hostname used to run your server application with `*` at the end.
411 | For example `https://routex9wvywuq-codeready-workspaces.apps.openshift.io*`
412 | .. Change `Web Origins` to `*`
413 | .. Click on the *Save* button
414 | .. Click on the *Installation* tab, and select `Keycloak OIDC JSON` format. Copy the content displayed or use the `Download` button to save the configuration file.
415 | . Create new users for testing:
416 | .. Select *Users* on the left menu, and click on *View all users*.
417 | .. Click on *Add user* to create a new user. Pick a username you like for the *Username* field and click *Save*.
418 | .. Select the *Credentials* tab and set a password for this user. Set *Temporary* option to *OFF*.
419 | .. Click *Reset Password*
420 |
421 | === Testing Keycloak Authentication and Authorization
422 |
423 | . Close all opened terminals in Code Ready environment
424 | . Copy `Keycloak OIDC JSON` file into:
425 | .. `server/website/keycloak.json`
426 | .. `server/src/config/keycloak.json`
427 | . Execute `start server`. This command will start GraphQL server with embedded client.
428 | . Open Preview URL in the new window
429 | . Login window should appear.
430 | . Login using credentials you have choosen in keycloak
431 | . Press User icon in the top right corner.
432 | . You should see admin user profile with his roles
433 | . Go back to the task screen
434 | . Try to delete one of the created tasks
435 | . User will not be permitted to delete task as it does not have test role.
436 |
--------------------------------------------------------------------------------