├── .gitignore ├── LICENSE ├── README.md ├── app.env ├── bin ├── event_machine_projection.php └── reset.php ├── composer.json ├── composer.lock ├── config ├── api_router.php ├── autoload │ ├── .gitignore │ └── global.php ├── config.php ├── container.php └── development.config.php.dist ├── data └── .gitkeep ├── docker-compose.yml ├── env ├── postgres │ └── initdb.d │ │ ├── 01_event_streams_table.sql │ │ └── 02_projections_table.sql └── rabbit │ ├── broker_definitions.json │ └── rabbitmq.config ├── phpunit.xml.dist ├── public ├── index.php ├── stomp.min.js ├── swagger │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── index.html │ ├── oauth2-redirect.html │ ├── swagger-ui-bundle.js │ ├── swagger-ui-bundle.js.map │ ├── swagger-ui-standalone-preset.js │ ├── swagger-ui-standalone-preset.js.map │ ├── swagger-ui.css │ ├── swagger-ui.css.map │ ├── swagger-ui.js │ └── swagger-ui.js.map └── ws.html ├── scripts └── create_event_stream.php ├── src ├── Api │ ├── Aggregate.php │ ├── Command.php │ ├── Event.php │ ├── Listener.php │ ├── Metadata.php │ ├── Payload.php │ ├── Projection.php │ ├── Query.php │ ├── Schema.php │ └── Type.php ├── Http │ ├── MessageSchemaMiddleware.php │ └── OriginalUriMiddleware.php ├── Infrastructure │ ├── Logger │ │ └── PsrErrorLogger.php │ ├── ServiceBus │ │ ├── CommandBus.php │ │ ├── ErrorHandler.php │ │ ├── EventBus.php │ │ ├── QueryBus.php │ │ └── UiExchange.php │ └── System │ │ └── HealthCheckResolver.php └── Service │ └── ServiceFactory.php └── tests ├── BaseTestCase.php └── FlavourContainer.php /.gitignore: -------------------------------------------------------------------------------- 1 | nbproject 2 | ._* 3 | .~lock.* 4 | .buildpath 5 | .DS_Store 6 | .idea 7 | .php_cs.cache 8 | .project 9 | .settings 10 | vendor 11 | data/* 12 | !data/.gitkeep 13 | src/Example/* 14 | bin/example.php 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2017, prooph software GmbH 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the prooph software GmbH nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # event-machine-skeleton 2 | Dockerized skeleton for prooph software [Event Machine](https://github.com/proophsoftware/event-machine) 3 | 4 | ## Installation 5 | Please make sure you have installed [Docker](https://docs.docker.com/engine/installation/ "Install Docker") and [Docker Compose](https://docs.docker.com/compose/install/ "Install Docker Compose"). 6 | 7 | ```bash 8 | $ docker run --rm -it -v $(pwd):/app prooph/composer:7.1 create-project proophsoftware/event-machine-skeleton 9 | $ cd 10 | $ sudo chown $(id -u -n):$(id -g -n) . -R 11 | $ docker-compose up -d 12 | $ docker-compose run php php scripts/create_event_stream.php 13 | ``` 14 | ## Tutorial 15 | 16 | [https://proophsoftware.github.io/event-machine/tutorial/](https://proophsoftware.github.io/event-machine/tutorial/) 17 | 18 | ## Demo 19 | 20 | We've prepared a `demo` branch that contains a small service called `BuildingMgmt`. It will show you the basics of 21 | event machine and the skeleton structure. To run the demo you have to clone the skeleton instead of 22 | `composer create-project` so that your local copy is still connected to the github repo. 23 | 24 | *Note: Event Machine is very flexible in the way how you organize your code. The skeleton just gives an example of a possible structure. 25 | The default way is to use static aggregate methods as pure functions. However, it is also possible to use stateful OOP aggregates. Take a look at the [tutorial](https://proophsoftware.github.io/event-machine/tutorial/) for more information. 26 | 27 | ```bash 28 | $ git clone https://github.com/proophsoftware/event-machine-skeleton.git prooph-building-mgmt 29 | $ cd prooph-building-mgmt 30 | $ git checkout demo 31 | $ docker run --rm -it -v $(pwd):/app prooph/composer:7.1 install 32 | $ docker-compose up -d 33 | $ docker-compose run php php scripts/create_event_stream.php 34 | ``` 35 | 36 | Head over to `http://localhost:8080` to check if the containers are up and running. 37 | You should see a "It works" message. 38 | 39 | ### Database 40 | 41 | The skeleton uses a single Postgres database for both write and read model. The write model is event sourced and writes 42 | all events to prooph/event-store. The read model is created by projections (see `src/Api/Projection`) and is also stored in 43 | the Postgres DB. Read model tables have the prefix `em_ds_` and end with a version number which is by default `0_1_0`. 44 | 45 | You can connect to the Postgres DB using following credentials (listed also in `app.env`): 46 | 47 | ```dotenv 48 | PDO_DSN=pgsql:host=postgres port=5432 dbname=event_machine 49 | PDO_USER=postgres 50 | PDO_PWD= 51 | ``` 52 | 53 | *Note: The DB runs insight a docker container. Use `localhost` as host name if you want to connect from your host system!* 54 | 55 | ### RabbitMQ 56 | 57 | The skeleton uses RabbitMQ as a message broker with a preconfigured exchange called `ui-exchange` and a corresponding 58 | queue called `ui-queue`. You can open the Rabbit Mgmt UI in the browser: `http://localhost:8081` and login with `user: prooph` 59 | and `password: prooph`. 60 | 61 | The skeleton also contains a demo JS client which connects to a websocket and consumes messages from the `ui-queue`. 62 | Open `http://localhost:8080/ws.html` in your browser and forward events on the queue with `$eventMachine->on(Event::MY_EVENT, UiExchange::class)`. 63 | Check `src/Api/Listener` for an example. 64 | 65 | ## Unit and Integration Tests 66 | 67 | We've prepared a `BaseTestCase` located in `tests`. Extend your test cases from that class to get access to some very useful test helpers. 68 | Check the tutorial for a detailed explanation. 69 | 70 | You can run the tests using docker: 71 | 72 | ```bash 73 | docker-compose run php php vendor/bin/phpunit 74 | ``` 75 | 76 | ## Troubleshooting 77 | 78 | With the command `docker-compose ps` you can list the running containers. This should look like the following list: 79 | 80 | ```bash 81 | Name Command State Ports 82 | --------------------------------------------------------------------------------------------------------------------------------------------------- 83 | proophbuildingmgmt_event_machine_projection_1 docker-php-entrypoint php ... Up 84 | proophbuildingmgmt_nginx_1 nginx -g daemon off; Up 0.0.0.0:443->443/tcp, 0.0.0.0:8080->80/tcp 85 | proophbuildingmgmt_php_1 docker-php-entrypoint php-fpm Up 9000/tcp 86 | proophbuildingmgmt_postgres_1 docker-entrypoint.sh postgres Up 0.0.0.0:5432->5432/tcp 87 | proophbuildingmgmt_rabbit_1 docker-entrypoint.sh rabbi ... Up 0.0.0.0:8081->15671/tcp, 15672/tcp, 88 | 0.0.0.0:15691->15691/tcp, 25672/tcp, 4369/tcp, 5671/tcp, 89 | 5672/tcp 90 | ``` 91 | 92 | Make sure that all required ports are available on your machine. If not you can modify port mapping in the `docker-compose.yml`. 93 | 94 | ### Have you tried turning it off and on again? 95 | 96 | If something does not work as expected try to restart the containers first: 97 | 98 | ```bash 99 | $ docker-compose down 100 | $ docker-compose up -d 101 | ``` 102 | 103 | ### Projection reset 104 | 105 | Event machine uses a single projection process (read more about prooph projections in the [prooph docs](http://docs.getprooph.org/event-store/projections.html#3-4)). 106 | You can register your own projections in event machine which are all handled by the one background process that is started automatically 107 | with the script `bin/event_machine_projection.php`. Also see `docker-compose.yml`. The projection process runs in its own docker container 108 | which is restarted by docker in case of a failure. The projection process dies from time to time to catch up with your latest code changes. 109 | 110 | If you recognize that your read models are not up-to-date or you need to reset the read model you can use this command: 111 | 112 | ```bash 113 | $ docker-compose run php php bin/reset.php 114 | ``` 115 | 116 | If you still have trouble try a step by step approach: 117 | 118 | ```bash 119 | $ docker-compose stop event_machine_projection 120 | $ docker-compose run php php bin/reset.php 121 | $ docker-compose up -d 122 | ``` 123 | 124 | You can also check the projection log with: 125 | 126 | ```bash 127 | $ docker-compose logs -f event_machine_projection 128 | ``` 129 | 130 | ### Swagger UI is not updated 131 | 132 | When you add new commands or queries in event machine the Swagger UI will not automatically reread the schema from the backend. 133 | Simply reload the UI or press `Explore` button. 134 | 135 | 136 | ## Batteries Included 137 | 138 | You know the headline from Docker, right? 139 | The Event Machine skeleton follows the same principle. It ships with a default set up so that you can start without messing around with configuration and such. 140 | The default set up is likely not what you want to use in production. The skeleton can be and **should be** adapted. 141 | 142 | Focus of the skeleton is to provide *an easy to use development environment*, hence it uses default settings of Postgres and RabbitMQ containers. 143 | **Make sure to secure the containers before you deploy them anywhere!** You should build and use your own docker containers in production anyway. 144 | And if you cannot or don't want to use Docker then provide the needed infrastructure the way you prefer and just point event machine to it by adjusting configuration. 145 | 146 | ## Powered by prooph software 147 | 148 | [![prooph software](https://github.com/codeliner/php-ddd-cargo-sample/blob/master/docs/assets/prooph-software-logo.png)](http://prooph.de) 149 | 150 | Event Machine is maintained by the [prooph software team](http://prooph-software.de/). The source code of Event Machine 151 | is open sourced along with an API documentation and a getting started demo. Prooph software offers commercial support and workshops 152 | for Event Machine as well as for the [prooph components](http://getprooph.org/). 153 | 154 | If you are interested in this offer or need project support please [get in touch](http://getprooph.org/#get-in-touch). 155 | -------------------------------------------------------------------------------- /app.env: -------------------------------------------------------------------------------- 1 | # Postgres Runtime 2 | POSTGRES_DB=event_machine 3 | 4 | # PHP Runtime 5 | PROOPH_ENV=dev 6 | 7 | # Storage credentials 8 | PDO_DSN=pgsql:host=postgres port=5432 dbname=event_machine 9 | PDO_USER=postgres 10 | PDO_PWD= 11 | -------------------------------------------------------------------------------- /bin/event_machine_projection.php: -------------------------------------------------------------------------------- 1 | get(\Prooph\EventMachine\EventMachine::class); 13 | 14 | $eventMachine->bootstrap(getenv('PROOPH_ENV')?: 'prod', true); 15 | 16 | $iterations = 0; 17 | 18 | while (true) { 19 | $devMode = $eventMachine->env() === \Prooph\EventMachine\EventMachine::ENV_DEV; 20 | 21 | $eventMachine->runProjections(!$devMode); 22 | 23 | $iterations++; 24 | 25 | if($iterations > 100) { 26 | //force reload in dev mode by exiting with error so docker restarts the container 27 | exit(1); 28 | } 29 | 30 | usleep(100); 31 | } 32 | -------------------------------------------------------------------------------- /bin/reset.php: -------------------------------------------------------------------------------- 1 | get(\Prooph\EventMachine\EventMachine::class); 13 | 14 | $eventMachine->bootstrap(getenv('PROOPH_ENV')?: 'prod', true); 15 | 16 | /** @var \Prooph\EventStore\Projection\ProjectionManager $projectionManager */ 17 | $projectionManager = $container->get(\Prooph\EventMachine\EventMachine::SERVICE_ID_PROJECTION_MANAGER); 18 | 19 | echo "Resetting " . \Prooph\EventMachine\Projecting\ProjectionRunner::eventMachineProjectionName($eventMachine->appVersion()) . "\n"; 20 | 21 | $projectionManager->resetProjection(\Prooph\EventMachine\Projecting\ProjectionRunner::eventMachineProjectionName($eventMachine->appVersion())); 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "proophsoftware/event-machine-skeleton", 3 | "description": "Dockerized skeleton for prooph software Event Machine", 4 | "homepage": "http://prooph.de/", 5 | "license": "BSD-3-Clause", 6 | "authors": [ 7 | { 8 | "name": "Alexander Miertsch", 9 | "email": "contact@prooph.de", 10 | "homepage": "http://www.prooph.de" 11 | }, 12 | { 13 | "name": "Sandro Keil", 14 | "email": "contact@prooph.de", 15 | "homepage": "http://prooph-software.com/" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.1", 20 | "roave/security-advisories": "dev-master", 21 | "proophsoftware/event-machine": "^v0.21", 22 | "proophsoftware/postgres-document-store": "^0.3", 23 | "prooph/pdo-event-store": "^1.0", 24 | "prooph/humus-amqp-producer": "^2.0", 25 | "zendframework/zend-stdlib": "^3.1.0", 26 | "zendframework/zend-config-aggregator": "^0.2.0", 27 | "zendframework/zend-stratigility": "^3.0", 28 | "zendframework/zend-expressive-helpers": "^5.0", 29 | "nikic/fast-route": "^1.0", 30 | "psr/log": "^1.0", 31 | "monolog/monolog": "^1.21", 32 | "psr/http-server-middleware": "^1.0", 33 | "zendframework/zend-problem-details": "^1.0" 34 | }, 35 | "require-dev": { 36 | "phpunit/phpunit": "^6.0" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "App\\": "src/" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "AppTest\\": "tests/" 46 | } 47 | }, 48 | "minimum-stability": "dev", 49 | "prefer-stable": true, 50 | "scripts": { 51 | "test": "vendor/bin/phpunit" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /config/api_router.php: -------------------------------------------------------------------------------- 1 | addRoute( 6 | ['POST'], 7 | '/messagebox', 8 | \Prooph\EventMachine\Http\MessageBox::class 9 | ); 10 | 11 | $r->addRoute( 12 | ['POST'], 13 | '/messagebox/{message_name:[A-Za-z0-9_.-\/]+}', 14 | \Prooph\EventMachine\Http\MessageBox::class 15 | ); 16 | 17 | $r->addRoute( 18 | ['GET'], 19 | '/messagebox-schema', 20 | \App\Http\MessageSchemaMiddleware::class 21 | ); 22 | }); -------------------------------------------------------------------------------- /config/autoload/.gitignore: -------------------------------------------------------------------------------- 1 | local.php 2 | *.local.php -------------------------------------------------------------------------------- /config/autoload/global.php: -------------------------------------------------------------------------------- 1 | getenv('PROOPH_ENV')?: 'prod', 16 | 'pdo' => [ 17 | 'dsn' => getenv('PDO_DSN'), 18 | 'user' => getenv('PDO_USER'), 19 | 'pwd' => getenv('PDO_PWD'), 20 | ], 21 | 'rabbit' => [ 22 | 'connection' => [ 23 | 'host' => getenv('RABBIT_HOST')?: 'rabbit', 24 | 'port' => (int)getenv('RABBIT_PORT')?: 5672, 25 | 'login' => getenv('RABBIT_USER')?: 'event-machine', 26 | 'password' => getenv('RABBIT_PWD')?: 'event-machine', 27 | 'vhost' => getenv('RABBIT_VHOST')?: '/event-machine', 28 | 'persistent' => (bool)getenv('RABBIT_PERSISTENT')?: false, 29 | 'read_timeout' => (int)getenv('RABBIT_READ_TIMEOUT')?: 1, //sec, float allowed 30 | 'write_timeout' => (int)getenv('RABBIT_WRITE_TIMEOUT')?: 1, //sec, float allowed, 31 | 'heartbeat' => (int)getenv('RABBIT_HEARTBEAT')?: 0, 32 | 'verify' => false 33 | ], 34 | 'ui_exchange' => getenv('RABBIT_UI_EXCHANGE')?: 'ui-exchange', 35 | ], 36 | 'event_machine' => [ 37 | 'descriptions' => [ 38 | Type::class, 39 | Command::class, 40 | Event::class, 41 | Query::class, 42 | Aggregate::class, 43 | Projection::class, 44 | Listener::class, 45 | ] 46 | ] 47 | ]; -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | 'data/config-cache.php', 13 | ]; 14 | $aggregator = new ConfigAggregator([ 15 | new ArrayProvider($cacheConfig), 16 | new PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'), 17 | new PhpFileProvider('config/development.config.php'), 18 | ], $cacheConfig['config_cache_path']); 19 | return $aggregator->getMergedConfig(); -------------------------------------------------------------------------------- /config/container.php: -------------------------------------------------------------------------------- 1 | \Prooph\EventStore\EventStore::class, 13 | \Prooph\EventMachine\EventMachine::SERVICE_ID_PROJECTION_MANAGER => \Prooph\EventStore\Projection\ProjectionManager::class, 14 | \Prooph\EventMachine\EventMachine::SERVICE_ID_COMMAND_BUS => \App\Infrastructure\ServiceBus\CommandBus::class, 15 | \Prooph\EventMachine\EventMachine::SERVICE_ID_EVENT_BUS => \App\Infrastructure\ServiceBus\EventBus::class, 16 | \Prooph\EventMachine\EventMachine::SERVICE_ID_QUERY_BUS => \App\Infrastructure\ServiceBus\QueryBus::class, 17 | \Prooph\EventMachine\EventMachine::SERVICE_ID_DOCUMENT_STORE => \Prooph\EventMachine\Persistence\DocumentStore::class, 18 | ] 19 | ); 20 | 21 | $serviceFactory->setContainer($container); 22 | 23 | return $container; -------------------------------------------------------------------------------- /config/development.config.php.dist: -------------------------------------------------------------------------------- 1 | true, 28 | ConfigAggregator::ENABLE_CACHE => false, 29 | ]; -------------------------------------------------------------------------------- /data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proophsoftware/event-machine-skeleton/3d3e83fe11333f90f40e2d33ea6e3e6c9f643f88/data/.gitkeep -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | nginx: 5 | image: prooph/nginx:www 6 | ports: 7 | - 8080:80 8 | - 443:443 9 | links: 10 | - php:php 11 | volumes: 12 | - .:/var/www 13 | 14 | php: 15 | image: prooph/php:7.2-fpm 16 | volumes: 17 | - .:/var/www 18 | env_file: 19 | - ./app.env 20 | 21 | event_machine_projection: 22 | image: prooph/php:7.2-cli 23 | volumes: 24 | - .:/app 25 | depends_on: 26 | - postgres 27 | command: php /app/bin/event_machine_projection.php 28 | # Needed so that projection is automatically restarted when new events are registered in event machine 29 | # which are not yet known in the long-running projection process, see https://github.com/proophsoftware/event-machine-skeleton/issues/3 30 | restart: on-failure 31 | env_file: 32 | - ./app.env 33 | 34 | rabbit: 35 | image: prooph/rabbitmq 36 | ports: 37 | - 8081:15671 38 | - 15691:15691 39 | volumes: 40 | - ./env/rabbit/broker_definitions.json:/opt/definitions.json:ro 41 | - ./env/rabbit/rabbitmq.config:/etc/rabbitmq/rabbitmq-prooph.config 42 | env_file: 43 | - ./app.env 44 | 45 | postgres: 46 | image: postgres:alpine 47 | ports: 48 | - 5432:5432 49 | env_file: 50 | - ./app.env 51 | volumes: 52 | - ./env/postgres/initdb.d:/docker-entrypoint-initdb.d:ro 53 | - data-postgres:/var/lib/postgresql/data 54 | 55 | volumes: 56 | data-postgres: 57 | driver: local 58 | -------------------------------------------------------------------------------- /env/postgres/initdb.d/01_event_streams_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE event_streams ( 2 | no BIGSERIAL, 3 | real_stream_name VARCHAR(150) NOT NULL, 4 | stream_name CHAR(41) NOT NULL, 5 | metadata JSONB, 6 | category VARCHAR(150), 7 | PRIMARY KEY (no), 8 | UNIQUE (stream_name) 9 | ); 10 | CREATE INDEX on event_streams (category); 11 | -------------------------------------------------------------------------------- /env/postgres/initdb.d/02_projections_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE projections ( 2 | no BIGSERIAL, 3 | name VARCHAR(150) NOT NULL, 4 | position JSONB, 5 | state JSONB, 6 | status VARCHAR(28) NOT NULL, 7 | locked_until CHAR(26), 8 | PRIMARY KEY (no), 9 | UNIQUE (name) 10 | ); 11 | -------------------------------------------------------------------------------- /env/rabbit/broker_definitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "rabbit_version": "3.6.11", 3 | "users": [ 4 | { 5 | "name": "prooph", 6 | "password_hash": "SxKhlLCGTegGcVTMyfQqJZAHJArM9wQi/CcPExWFbmax0f9V", 7 | "hashing_algorithm": "rabbit_password_hashing_sha256", 8 | "tags": "administrator" 9 | }, 10 | { 11 | "name": "event-machine", 12 | "password_hash": "Uy8ulMhmZ7VQA2ZuKw4AVIrpKluNxdyOCbdyfBguhB+q6S9a", 13 | "hashing_algorithm": "rabbit_password_hashing_sha256", 14 | "tags": "" 15 | } 16 | ], 17 | "vhosts": [ 18 | { 19 | "name": "/" 20 | }, 21 | { 22 | "name": "/event-machine" 23 | } 24 | ], 25 | "permissions": [ 26 | { 27 | "user": "prooph", 28 | "vhost": "/", 29 | "configure": ".*", 30 | "write": ".*", 31 | "read": ".*" 32 | }, 33 | { 34 | "user": "prooph", 35 | "vhost": "/event-machine", 36 | "configure": ".*", 37 | "write": ".*", 38 | "read": ".*" 39 | }, 40 | { 41 | "user": "event-machine", 42 | "vhost": "/event-machine", 43 | "configure": ".*", 44 | "write": ".*", 45 | "read": ".*" 46 | } 47 | ], 48 | "parameters": [], 49 | "global_parameters": [ 50 | { 51 | "name": "cluster_name", 52 | "value": "prooph@ce28ff581fbc" 53 | } 54 | ], 55 | "policies": [], 56 | "queues": [ 57 | { 58 | "name": "ui-queue", 59 | "vhost": "/event-machine", 60 | "durable": true, 61 | "auto_delete": false, 62 | "arguments": {} 63 | } 64 | ], 65 | "exchanges": [ 66 | { 67 | "name": "ui-exchange", 68 | "vhost": "/event-machine", 69 | "type": "fanout", 70 | "durable": true, 71 | "auto_delete": false, 72 | "internal": false, 73 | "arguments": {} 74 | } 75 | ], 76 | "bindings": [ 77 | { 78 | "source": "ui-exchange", 79 | "vhost": "/event-machine", 80 | "destination": "ui-queue", 81 | "destination_type": "queue", 82 | "routing_key": "#", 83 | "arguments": {} 84 | } 85 | ] 86 | } -------------------------------------------------------------------------------- /env/rabbit/rabbitmq.config: -------------------------------------------------------------------------------- 1 | [ 2 | { rabbit, [ 3 | { loopback_users, [ ] }, 4 | { tcp_listeners, [ 5672 ] } 5 | ] }, 6 | { rabbitmq_management, [ { listener, [ 7 | { port, 15671 }, 8 | { ssl, true }, 9 | { ssl_opts, [ 10 | { cacertfile, "/etc/rabbitmq/ssl/cacert.pem" }, 11 | { certfile, "/etc/rabbitmq/ssl/localhost.crt" }, 12 | { fail_if_no_peer_cert, false }, 13 | { keyfile, "/etc/rabbitmq/ssl/localhost.key" }, 14 | { verify, verify_none } 15 | ] } 16 | ] }, {load_definitions, "/opt/definitions.json"} ] }, 17 | { rabbitmq_web_stomp, [ { ssl_config, [ 18 | { port, 15691 }, 19 | { backlog, 1024 }, 20 | { cacertfile, "/etc/rabbitmq/ssl/cacert.pem" }, 21 | { certfile, "/etc/rabbitmq/ssl/localhost.crt" }, 22 | { keyfile, "/etc/rabbitmq/ssl/localhost.key" }, 23 | { password, "" } 24 | ] } ] } 25 | ]. -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | ./tests/ 15 | 16 | 17 | 18 | 19 | ./src/ 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | pipe($container->get(\Zend\ProblemDetails\ProblemDetailsMiddleware::class)); 22 | 23 | $app->pipe(new \Zend\Expressive\Helper\BodyParams\BodyParamsMiddleware()); 24 | 25 | $app->pipe(new \App\Http\OriginalUriMiddleware()); 26 | 27 | $app->pipe(\Zend\Stratigility\path( 28 | '/api', 29 | \Zend\Stratigility\middleware(function (Request $req, RequestHandler $handler) use($container, $env, $devMode): Response { 30 | /** @var FastRoute\Dispatcher $router */ 31 | $router = require 'config/api_router.php'; 32 | 33 | $route = $router->dispatch($req->getMethod(), $req->getUri()->getPath()); 34 | 35 | if ($route[0] === FastRoute\Dispatcher::NOT_FOUND) { 36 | return new \Zend\Diactoros\Response\EmptyResponse(404); 37 | } 38 | 39 | if ($route[0] === FastRoute\Dispatcher::METHOD_NOT_ALLOWED) { 40 | return new \Zend\Diactoros\Response\EmptyResponse(405); 41 | } 42 | 43 | foreach ($route[2] as $name => $value) { 44 | $req = $req->withAttribute($name, $value); 45 | } 46 | 47 | if(!$container->has($route[1])) { 48 | throw new \RuntimeException("Http handler not found. Got " . $route[1]); 49 | } 50 | 51 | $container->get(\Prooph\EventMachine\EventMachine::class)->bootstrap($env, $devMode); 52 | 53 | /** @var RequestHandler $httpHandler */ 54 | $httpHandler = $container->get($route[1]); 55 | 56 | return $httpHandler->handle($req); 57 | }) 58 | )); 59 | 60 | $app->pipe(\Zend\Stratigility\path('/', \Zend\Stratigility\middleware(function (Request $request, $handler): Response { 61 | //@TODO add homepage with infos about event-machine and the skeleton 62 | return new \Zend\Diactoros\Response\TextResponse("It works"); 63 | }))); 64 | 65 | $server = \Zend\Diactoros\Server::createServer( 66 | [$app, 'handle'], 67 | $_SERVER, 68 | $_GET, 69 | $_POST, 70 | $_COOKIE, 71 | $_FILES 72 | ); 73 | 74 | $server->listen(); 75 | 76 | -------------------------------------------------------------------------------- /public/stomp.min.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.7.1 2 | /* 3 | Stomp Over WebSocket http://www.jmesnil.net/stomp-websocket/doc/ | Apache License V2.0 4 | 5 | Copyright (C) 2010-2013 [Jeff Mesnil](http://jmesnil.net/) 6 | Copyright (C) 2012 [FuseSource, Inc.](http://fusesource.com) 7 | */ 8 | (function(){var t,e,n,i,r={}.hasOwnProperty,o=[].slice;t={LF:"\n",NULL:"\x00"};n=function(){var e;function n(t,e,n){this.command=t;this.headers=e!=null?e:{};this.body=n!=null?n:""}n.prototype.toString=function(){var e,i,o,s,u;e=[this.command];o=this.headers["content-length"]===false?true:false;if(o){delete this.headers["content-length"]}u=this.headers;for(i in u){if(!r.call(u,i))continue;s=u[i];e.push(""+i+":"+s)}if(this.body&&!o){e.push("content-length:"+n.sizeOfUTF8(this.body))}e.push(t.LF+this.body);return e.join(t.LF)};n.sizeOfUTF8=function(t){if(t){return encodeURI(t).match(/%..|./g).length}else{return 0}};e=function(e){var i,r,o,s,u,a,c,f,h,l,p,d,g,b,m,v,y;s=e.search(RegExp(""+t.LF+t.LF));u=e.substring(0,s).split(t.LF);o=u.shift();a={};d=function(t){return t.replace(/^\s+|\s+$/g,"")};v=u.reverse();for(g=0,m=v.length;gy;c=p<=y?++b:--b){r=e.charAt(c);if(r===t.NULL){break}i+=r}}return new n(o,a,i)};n.unmarshall=function(n){var i,r,o,s;r=n.split(RegExp(""+t.NULL+t.LF+"*"));s={frames:[],partial:""};s.frames=function(){var t,n,o,s;o=r.slice(0,-1);s=[];for(t=0,n=o.length;t>> "+r)}while(true){if(r.length>this.maxWebSocketFrameSize){this.ws.send(r.substring(0,this.maxWebSocketFrameSize));r=r.substring(this.maxWebSocketFrameSize);if(typeof this.debug==="function"){this.debug("remaining = "+r.length)}}else{return this.ws.send(r)}}};r.prototype._setupHeartbeat=function(n){var r,o,s,u,a,c;if((a=n.version)!==i.VERSIONS.V1_1&&a!==i.VERSIONS.V1_2){return}c=function(){var t,e,i,r;i=n["heart-beat"].split(",");r=[];for(t=0,e=i.length;t>> PING"):void 0}}(this))}if(!(this.heartbeat.incoming===0||o===0)){s=Math.max(this.heartbeat.incoming,o);if(typeof this.debug==="function"){this.debug("check PONG every "+s+"ms")}return this.ponger=i.setInterval(s,function(t){return function(){var n;n=e()-t.serverActivity;if(n>s*2){if(typeof t.debug==="function"){t.debug("did not receive server activity for the last "+n+"ms")}return t.ws.close()}}}(this))}};r.prototype._parseConnect=function(){var t,e,n,i;t=1<=arguments.length?o.call(arguments,0):[];i={};switch(t.length){case 2:i=t[0],e=t[1];break;case 3:if(t[1]instanceof Function){i=t[0],e=t[1],n=t[2]}else{i.login=t[0],i.passcode=t[1],e=t[2]}break;case 4:i.login=t[0],i.passcode=t[1],e=t[2],n=t[3];break;default:i.login=t[0],i.passcode=t[1],e=t[2],n=t[3],i.host=t[4]}return[i,e,n]};r.prototype.connect=function(){var r,s,u,a;r=1<=arguments.length?o.call(arguments,0):[];a=this._parseConnect.apply(this,r);u=a[0],this.connectCallback=a[1],s=a[2];if(typeof this.debug==="function"){this.debug("Opening Web Socket...")}this.ws.onmessage=function(i){return function(r){var o,u,a,c,f,h,l,p,d,g,b,m,v;c=typeof ArrayBuffer!=="undefined"&&r.data instanceof ArrayBuffer?(o=new Uint8Array(r.data),typeof i.debug==="function"?i.debug("--- got data length: "+o.length):void 0,function(){var t,e,n;n=[];for(t=0,e=o.length;t 2 | 3 | 4 | 5 | 6 | Swagger UI 7 | 8 | 9 | 10 | 11 | 32 | 33 | 34 | 35 |
36 | 37 | 38 | 39 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /public/swagger/oauth2-redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 68 | -------------------------------------------------------------------------------- /public/swagger/swagger-ui.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":[],"names":[],"mappings":"","file":"swagger-ui.css","sourceRoot":""} -------------------------------------------------------------------------------- /public/ws.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

UI Exchange

5 |

Status: connecting to websocket: ui-queue ...

6 |

You'll see a notification when a new message arrives

7 |

Double click notification to hide it.

8 | 9 | 10 | 11 | 45 | 46 | -------------------------------------------------------------------------------- /scripts/create_event_stream.php: -------------------------------------------------------------------------------- 1 | get('EventMachine.EventStore'); 19 | 20 | $eventStore->create(new Stream(new StreamName('event_stream'), new ArrayIterator())); 21 | 22 | echo "done.\n"; 23 | -------------------------------------------------------------------------------- /src/Api/Aggregate.php: -------------------------------------------------------------------------------- 1 | process(Command::REGISTER_USER) <-- Command name of the command that is expected by the Aggregate's handle method 32 | * ->withNew(self::USER) //<-- aggregate type, defined as constant above, also tell event machine that a new Aggregate should be created 33 | * ->identifiedBy(Payload::USER_ID) //<-- Payload property (of all user related commands) that identify the addressed User 34 | * ->handle([User::class, 'register']) //<-- Aggregates are stateless and have static callable methods that can be linked to using PHP's callable array syntax 35 | * ->recordThat(Event::USER_REGISTERED) //<-- Event name of the event yielded by the Aggregate's handle method 36 | * ->apply([User::class, 'whenUserRegistered']) //<-- Aggregate method (again static) that is called when event is recorded 37 | * ->orRecordThat(Event::DOUBLE_REGISTRATION_DETECTED) //Alternative event that can be yielded by the Aggregate's handle method 38 | * ->apply([User::class, 'whenDoubleRegistrationDetected']); //Again the method that should be called in case above event is recorded 39 | * 40 | * $eventMachine->process(Command::CHANGE_USERNAME) //<-- User::changeUsername() expects a Command::CHANGE_USERNAME command 41 | * ->withExisting(self::USER) //<-- Aggregate should already exist, Event Machine uses Payload::USER_ID to load User from event store 42 | * ->handle([User::class, 'changeUsername']) 43 | * ->recordThat(Event::USERNAME_CHANGED) 44 | * ->apply([User::class, 'whenUsernameChanged']); 45 | */ 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Api/Command.php: -------------------------------------------------------------------------------- 1 | registerCommand( 37 | * self::REGISTER_USER, //<-- Name of the command defined as constant above 38 | * JsonSchema::object([ 39 | * Payload::USER_ID => Schema::userId(), //<-- We only work with constants and domain specific reusable schemas 40 | * Payload::USERNAME => Schema::username(), //<-- See App\Api\Payload for property constants ... 41 | * Payload::EMAIL => Schema::email(), //<-- ... and App\Api\Schema for schema definitions 42 | * ]) 43 | * ); 44 | */ 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Api/Event.php: -------------------------------------------------------------------------------- 1 | registerEvent( 39 | * self::USER_REGISTERED, 40 | * JsonSchema::object([ 41 | * Payload::USER_ID => Schema::userId(), //<-- We only work with constants and domain specific reusable schemas 42 | * Payload::USERNAME => Schema::username(), //<-- See App\Api\Payload for property constants ... 43 | * Payload::EMAIL => Schema::email(), //<-- ... and App\Api\Schema for schema definitions 44 | * // See also App\Api\Command, same schema definitions are used there 45 | * ]) 46 | * ); 47 | */ 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Api/Listener.php: -------------------------------------------------------------------------------- 1 | on(Event::USER_REGISTERED, VerificationMailer::class); 24 | */ 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Api/Metadata.php: -------------------------------------------------------------------------------- 1 | get(Payload::USERNAME); //This is readable and eases refactoring in a larger code base. 21 | */ 22 | 23 | //Predefined keys for query payloads, see App\Api\Schema::queryPagination() for further information 24 | const SKIP = 'skip'; 25 | const LIMIT = 'limit'; 26 | } 27 | -------------------------------------------------------------------------------- /src/Api/Projection.php: -------------------------------------------------------------------------------- 1 | watch(\Prooph\EventMachine\Persistence\Stream::ofWriteModel()) 29 | * ->withAggregateProjection(Aggregate::USER); 30 | * 31 | * Note: \Prooph\EventMachine\Projecting\AggregateProjector::aggregateCollectionName(string $aggregateType) 32 | * will be used to generate a collection name for the aggregate data to be stored (as documents). 33 | * This means that a query resolver (@see \App\Api\Query) should use the same method to generate the collection name 34 | * 35 | * Register a custom projection 36 | * 37 | * $eventMachine->watch(\Prooph\EventMachine\Persistence\Stream::ofWriteModel()) 38 | * ->with(self::USER_FRIENDS, UserFriendsProjector::class) //<-- Custom projection name and Projector service id (for DI container) 39 | * //Projector should implement Prooph\EventMachine\Projecting\Projector 40 | * ->filterEvents([Event::USER_ADDED, EVENT::FRIEND_LINKED]); //Projector is only interested in listed events 41 | */ 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Api/Query.php: -------------------------------------------------------------------------------- 1 | registerQuery(self::HEALTH_CHECK) //<-- Payload schema is optional for queries 35 | ->resolveWith(HealthCheckResolver::class) //<-- Service id (usually FQCN) to get resolver from DI container 36 | ->setReturnType(Schema::healthCheck()); //<-- Type returned by resolver 37 | 38 | /** 39 | * Register queries and if they have arguments (like filters, skip, limit, orderBy arguments) 40 | * you define the schema of that arguments as query payload 41 | * 42 | * You also tell event machine which resolver should be used to resolve the query. 43 | * The resolver is requested from the PSR-11 DI container used by event machine. 44 | * 45 | * Each query also has a return type, which can be a JsonSchema::TYPE_ARRAY or one of the scalar JsonSchema types. 46 | * If the query returns an object (for example user data), this object should be registered in EventMachine as a Type 47 | * @see \App\Api\Type for details 48 | * @see \App\Api\Schema for best practise of how to reuse return type schemas 49 | * 50 | * @example 51 | * 52 | * //Register User query with Payload::USER_ID as required argument, Schema::userId() is reused here, so that only valid 53 | * //user ids are passed to the resolver 54 | * $eventMachine->registerQuery(self::User, JsonSchema::object([Payload::USER_ID => Schema::userId()])) 55 | * ->resolveWith(UserResolver::class) 56 | * ->setReturnType(Schema::user()); //<-- Pass type reference as return type, @see \App\Api\Schema::user() (in the comment) for details 57 | * 58 | * //Register a second query to load many Users, this query takes an optional Payload::ACTIVE argument 59 | * $eventMachine->registerQuery(self::Users, JsonSchema::object([], [ 60 | * Payload::ACTIVE => JsonSchema::nullOr(JsonSchema::boolean()) 61 | * ])) 62 | * ->resolveWith(UsersResolver::class) 63 | * ->setReturnType(JsonSchema::array(Schema::user())); //<-- Return type is an array of Schema::user() (type reference to Type::USER) 64 | */ 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Api/Schema.php: -------------------------------------------------------------------------------- 1 | 1]) 35 | * } 36 | */ 37 | 38 | /** 39 | * Common schema definitions that are useful in nearly any application. 40 | * Add more or remove unneeded depending on project needs. 41 | */ 42 | public static function healthCheck(): TypeRef 43 | { 44 | //Health check schema is a type reference to the registered Type::HEALTH_CHECK 45 | return JsonSchema::typeRef(Type::HEALTH_CHECK); 46 | } 47 | 48 | 49 | /** 50 | * Can be used as JsonSchema::object() (optional) property definition in query payloads to enable pagination 51 | * @return array 52 | */ 53 | public static function queryPagination(): array 54 | { 55 | return [ 56 | Payload::SKIP => JsonSchema::nullOr(JsonSchema::integer(['minimum' => 0])), 57 | Payload::LIMIT => JsonSchema::nullOr(JsonSchema::integer(['minimum' => 1])), 58 | ]; 59 | } 60 | 61 | public static function iso8601DateTime(): StringType 62 | { 63 | return JsonSchema::string()->withPattern('^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(([+-]\d\d:\d\d)|Z)?$'); 64 | } 65 | 66 | public static function iso8601Date(): StringType 67 | { 68 | return JsonSchema::string()->withPattern('^\d{4}-\d\d-\d\d$'); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Api/Type.php: -------------------------------------------------------------------------------- 1 | Schema::userId(), 28 | * Payload::USERNAME => Schema::username() 29 | * ]) 30 | * } 31 | * 32 | * Queries should only use type references as return types (at least when return type is an object). 33 | * @see \App\Api\Query for more about query return types 34 | */ 35 | 36 | 37 | const HEALTH_CHECK = 'HealthCheck'; 38 | 39 | private static function healthCheck(): ObjectType 40 | { 41 | return JsonSchema::object([ 42 | 'system' => JsonSchema::boolean() 43 | ]); 44 | } 45 | 46 | /** 47 | * @param EventMachine $eventMachine 48 | */ 49 | public static function describe(EventMachine $eventMachine): void 50 | { 51 | //Register the HealthCheck type returned by @see \App\Api\Query::HEALTH_CHECK 52 | $eventMachine->registerType(self::HEALTH_CHECK, self::healthCheck()); 53 | 54 | /** 55 | * Register all types returned by queries 56 | * @see \App\Api\Query for more details about return types 57 | * 58 | * @example 59 | * 60 | * $eventMachine->registerType(self::USER, self::user()); 61 | */ 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Http/MessageSchemaMiddleware.php: -------------------------------------------------------------------------------- 1 | eventMachine = $eventMachine; 25 | } 26 | 27 | 28 | /** 29 | * Handle the request and return a response. 30 | * 31 | * @param ServerRequestInterface $request 32 | * 33 | * @return ResponseInterface 34 | */ 35 | public function handle(ServerRequestInterface $request): ResponseInterface 36 | { 37 | /** @var UriInterface $uri */ 38 | $uri = $request->getAttribute('original_uri', $request->getUri()); 39 | 40 | $serverUrl = $uri->withPath(str_replace('-schema', '', $uri->getPath())); 41 | 42 | $eventMachineSchema = $this->eventMachine->messageBoxSchema(); 43 | 44 | $paths = []; 45 | 46 | foreach ($eventMachineSchema['properties']['commands'] as $messageName => $schema) { 47 | [$path, $pathDef] = $this->messageSchemaToPath($messageName, Message::TYPE_COMMAND, $schema); 48 | $paths[$path] = $pathDef; 49 | } 50 | 51 | foreach ($eventMachineSchema['properties']['events'] as $messageName => $schema) { 52 | [$path, $pathDef] = $this->messageSchemaToPath($messageName, Message::TYPE_EVENT, $schema); 53 | $paths[$path] = $pathDef; 54 | } 55 | 56 | foreach ($eventMachineSchema['properties']['queries'] as $messageName => $schema) { 57 | [$path, $pathDef] = $this->messageSchemaToPath($messageName, Message::TYPE_QUERY, $schema); 58 | $paths[$path] = $pathDef; 59 | } 60 | 61 | $componentSchemas = []; 62 | 63 | foreach ($eventMachineSchema['definitions'] ?? [] as $componentName => $componentSchema) { 64 | $componentSchemas[$componentName] = $this->jsonSchemaToOpenApiSchema($componentSchema); 65 | } 66 | 67 | $schema = [ 68 | 'openapi' => '3.0.0', 69 | 'servers' => [ 70 | [ 71 | 'description' => 'Event Machine ' . $this->eventMachine->env() . ' server', 72 | 'url' => (string)$serverUrl 73 | ] 74 | ], 75 | 'info' => [ 76 | 'description' => 'An endpoint for sending messages to the application.', 77 | 'version' => $this->eventMachine->appVersion(), 78 | 'title' => 'Event Machine Message Box' 79 | ], 80 | 'tags' => [ 81 | [ 82 | 'name' => 'queries', 83 | 'description' => 'Requests to read data from the system' 84 | ], 85 | [ 86 | 'name' => 'commands', 87 | 'description' => 'Requests to write data to the system or execute an action', 88 | ], 89 | [ 90 | 'name' => 'events', 91 | 'description' => 'Requests to add an event to the system' 92 | ] 93 | ], 94 | 'paths' => $paths, 95 | 'components' => ['schemas' => $componentSchemas], 96 | ]; 97 | 98 | return new JsonResponse($schema); 99 | } 100 | 101 | private function messageSchemaToPath(string $messageName, string $messageType, array $messageSchema = null): array 102 | { 103 | $responses = []; 104 | 105 | if($messageType === Message::TYPE_QUERY) { 106 | $responses['200'] = [ 107 | 'description' => $messageSchema['response']['description'] ?? $messageName, 108 | 'content' => [ 109 | 'application/json' => [ 110 | 'schema' => $this->jsonSchemaToOpenApiSchema($messageSchema['response']) 111 | ] 112 | ] 113 | ]; 114 | 115 | unset($messageSchema['response']); 116 | } else { 117 | $responses['202'] = [ 118 | 'description' => "$messageType accepted" 119 | ]; 120 | } 121 | 122 | switch ($messageType) { 123 | case Message::TYPE_COMMAND: 124 | $tag = 'commands'; 125 | break; 126 | case Message::TYPE_QUERY: 127 | $tag = 'queries'; 128 | break; 129 | case Message::TYPE_EVENT: 130 | $tag = 'events'; 131 | break; 132 | default: 133 | throw new \RuntimeException("Unknown message type given. Got $messageType"); 134 | 135 | } 136 | 137 | return [ 138 | "/{$messageName}", 139 | [ 140 | 'post' => [ 141 | 'tags' => [$tag], 142 | 'summary' => $messageName, 143 | 'operationId' => "$messageType.$messageName", 144 | 'description' => $messageSchema['description'] ?? "Send a $messageName $messageType", 145 | 'requestBody' => [ 146 | 'content' => [ 147 | 'application/json' => [ 148 | 'schema' => [ 149 | 'type' => 'object', 150 | 'properties' => [ 151 | 'payload' => $this->jsonSchemaToOpenApiSchema($messageSchema) 152 | ], 153 | 'required' => ['payload'] 154 | ] 155 | ] 156 | ] 157 | ], 158 | 'responses' => $responses 159 | ] 160 | ] 161 | ]; 162 | } 163 | 164 | private function jsonSchemaToOpenApiSchema(array $jsonSchema): array 165 | { 166 | if(isset($jsonSchema['type']) && is_array($jsonSchema['type'])) { 167 | $type = null; 168 | $containsNull = false; 169 | foreach ($jsonSchema['type'] as $possibleType) { 170 | if(mb_strtolower($possibleType) !== 'null') { 171 | if($type) { 172 | throw new \RuntimeException("Got JSON Schema type defined as an array with more than one type + NULL set. " . json_encode($jsonSchema)); 173 | } 174 | $type = $possibleType; 175 | } else { 176 | $containsNull = true; 177 | } 178 | } 179 | $jsonSchema['type'] = $type; 180 | if($containsNull) { 181 | $jsonSchema['nullable'] = true; 182 | } 183 | } 184 | 185 | if(isset($jsonSchema['properties']) && is_array($jsonSchema['properties'])) { 186 | foreach ($jsonSchema['properties'] as $propName => $propSchema) { 187 | $jsonSchema['properties'][$propName] = $this->jsonSchemaToOpenApiSchema($propSchema); 188 | } 189 | } 190 | 191 | if(isset($jsonSchema['items']) && is_array($jsonSchema['items'])) { 192 | $jsonSchema['items'] = $this->jsonSchemaToOpenApiSchema($jsonSchema['items']); 193 | } 194 | 195 | if(isset($jsonSchema['$ref'])) { 196 | $jsonSchema['$ref'] = str_replace('definitions', 'components/schemas', $jsonSchema['$ref']); 197 | } 198 | 199 | return $jsonSchema; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/Http/OriginalUriMiddleware.php: -------------------------------------------------------------------------------- 1 | withAttribute('original_uri', $request->getUri()); 26 | 27 | return $handler->handle($request); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Infrastructure/Logger/PsrErrorLogger.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 18 | } 19 | 20 | /** 21 | * Acts as a Zend\Stratigility\Middleware\ErrorHandler::attachListener() listener 22 | * 23 | * @param \Throwable $error 24 | * @param ServerRequestInterface $request 25 | * @param ResponseInterface $response 26 | */ 27 | public function __invoke(\Throwable $error, ServerRequestInterface $request, ResponseInterface $response) 28 | { 29 | $id = uniqid('request_'); 30 | $this->logger->info('Request ('.$id.'): [' . $request->getMethod() . '] ' . $request->getUri()); 31 | $this->logger->info('Request-Headers ('.$id.'): ' . json_encode($request->getHeaders())); 32 | $this->logger->info('Request-Body ('.$id.'): ' . $request->getBody()); 33 | $this->logger->error('Error ('.$id.'): ' . $error); 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/Infrastructure/ServiceBus/CommandBus.php: -------------------------------------------------------------------------------- 1 | messageName(); 16 | } 17 | 18 | return parent::getMessageName($message); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Infrastructure/ServiceBus/ErrorHandler.php: -------------------------------------------------------------------------------- 1 | handler = $messageBus->attach(MessageBus::EVENT_FINALIZE, function (ActionEvent $actionEvent): void { 20 | if ($exception = $actionEvent->getParam(MessageBus::EVENT_PARAM_EXCEPTION)) { 21 | if($deferred = $actionEvent->getParam(QueryBus::EVENT_PARAM_DEFERRED)) { 22 | $deferred->reject($exception); 23 | $actionEvent->setParam(MessageBus::EVENT_PARAM_EXCEPTION, null); 24 | return; 25 | } 26 | 27 | throw $exception; 28 | } 29 | }, QueryBus::PRIORITY_PROMISE_REJECT + 100); 30 | } 31 | 32 | public function detachFromMessageBus(MessageBus $messageBus): void 33 | { 34 | $messageBus->detach($this->handler); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Infrastructure/ServiceBus/EventBus.php: -------------------------------------------------------------------------------- 1 | messageName(); 16 | } 17 | 18 | return parent::getMessageName($message); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Infrastructure/ServiceBus/QueryBus.php: -------------------------------------------------------------------------------- 1 | messageName(); 16 | } 17 | 18 | return parent::getMessageName($message); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Infrastructure/ServiceBus/UiExchange.php: -------------------------------------------------------------------------------- 1 | resolve([ 15 | 'system' => true 16 | ]); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Service/ServiceFactory.php: -------------------------------------------------------------------------------- 1 | config = new ArrayReader($appConfig); 60 | } 61 | 62 | public function setContainer(ContainerInterface $container): void 63 | { 64 | $this->container = $container; 65 | } 66 | 67 | //HTTP endpoints 68 | public function httpMessageBox(): MessageBox 69 | { 70 | return $this->makeSingleton(MessageBox::class, function () { 71 | return $this->eventMachine()->httpMessageBox(); 72 | }); 73 | } 74 | 75 | public function eventMachineHttpMessageSchema(): MessageSchemaMiddleware 76 | { 77 | return $this->makeSingleton(MessageSchemaMiddleware::class, function () { 78 | return new MessageSchemaMiddleware($this->eventMachine()); 79 | }); 80 | } 81 | 82 | public function pdoConnection(): \PDO 83 | { 84 | return $this->makeSingleton(\PDO::class, function () { 85 | $this->assertMandatoryConfigExists('pdo.dsn'); 86 | $this->assertMandatoryConfigExists('pdo.user'); 87 | $this->assertMandatoryConfigExists('pdo.pwd'); 88 | 89 | return new \PDO( 90 | $this->config->stringValue('pdo.dsn'), 91 | $this->config->stringValue('pdo.user'), 92 | $this->config->stringValue('pdo.pwd') 93 | ); 94 | }); 95 | } 96 | 97 | protected function eventStorePersistenceStrategy(): PersistenceStrategy 98 | { 99 | return $this->makeSingleton(PersistenceStrategy::class, function () { 100 | return new PersistenceStrategy\PostgresSingleStreamStrategy(); 101 | }); 102 | } 103 | 104 | public function eventStore(): EventStore 105 | { 106 | return $this->makeSingleton(EventStore::class, function () { 107 | $eventStore = new PostgresEventStore( 108 | $this->eventMachine()->messageFactory(), 109 | $this->pdoConnection(), 110 | $this->eventStorePersistenceStrategy() 111 | ); 112 | 113 | return new TransactionalActionEventEmitterEventStore( 114 | $eventStore, 115 | new ProophActionEventEmitter(TransactionalActionEventEmitterEventStore::ALL_EVENTS) 116 | ); 117 | }); 118 | } 119 | 120 | public function documentStore(): DocumentStore 121 | { 122 | return $this->makeSingleton(DocumentStore::class, function () { 123 | return new PostgresDocumentStore( 124 | $this->pdoConnection(), 125 | null, 126 | 'CHAR(36) NOT NULL' //Use alternative docId schema, to allow uuids as well as md5 hashes 127 | ); 128 | }); 129 | } 130 | 131 | public function projectionManager(): ProjectionManager 132 | { 133 | return $this->makeSingleton(ProjectionManager::class, function () { 134 | return new PostgresProjectionManager( 135 | $this->eventStore(), 136 | $this->pdoConnection() 137 | ); 138 | }); 139 | } 140 | 141 | public function aggregateProjector(): AggregateProjector 142 | { 143 | return $this->makeSingleton(AggregateProjector::class, function () { 144 | return new AggregateProjector( 145 | $this->documentStore(), 146 | $this->eventMachine() 147 | ); 148 | }); 149 | } 150 | 151 | public function commandBus(): CommandBus 152 | { 153 | return $this->makeSingleton(CommandBus::class, function () { 154 | $commandBus = new CommandBus(); 155 | $errorHandler = new \App\Infrastructure\ServiceBus\ErrorHandler(); 156 | $errorHandler->attachToMessageBus($commandBus); 157 | return $commandBus; 158 | }); 159 | } 160 | 161 | public function eventBus(): EventBus 162 | { 163 | return $this->makeSingleton(EventBus::class, function () { 164 | $eventBus = new EventBus(); 165 | $errorHandler = new \App\Infrastructure\ServiceBus\ErrorHandler(); 166 | $errorHandler->attachToMessageBus($eventBus); 167 | return $eventBus; 168 | }); 169 | } 170 | 171 | public function queryBus(): QueryBus 172 | { 173 | return $this->makeSingleton(QueryBus::class, function () { 174 | $queryBus = new QueryBus(); 175 | $errorHandler = new \App\Infrastructure\ServiceBus\ErrorHandler(); 176 | $errorHandler->attachToMessageBus($queryBus); 177 | return $queryBus; 178 | }); 179 | } 180 | 181 | public function uiExchange(): UiExchange 182 | { 183 | return $this->makeSingleton(UiExchange::class, function () { 184 | $this->assertMandatoryConfigExists('rabbit.connection'); 185 | 186 | $connection = new \Humus\Amqp\Driver\AmqpExtension\Connection( 187 | $this->config->arrayValue('rabbit.connection') 188 | ); 189 | 190 | $connection->connect(); 191 | 192 | $channel = $connection->newChannel(); 193 | 194 | $exchange = $channel->newExchange(); 195 | 196 | $exchange->setName($this->config->stringValue('rabbit.ui_exchange', 'ui-exchange')); 197 | 198 | $exchange->setType('fanout'); 199 | 200 | $humusProducer = new \Humus\Amqp\JsonProducer($exchange); 201 | 202 | $messageProducer = new \Prooph\ServiceBus\Message\HumusAmqp\AmqpMessageProducer( 203 | $humusProducer, 204 | new NoOpMessageConverter() 205 | ); 206 | 207 | return new class($messageProducer) implements UiExchange { 208 | private $producer; 209 | public function __construct(AmqpMessageProducer $messageProducer) 210 | { 211 | $this->producer = $messageProducer; 212 | } 213 | 214 | public function __invoke(Message $event): void 215 | { 216 | $this->producer->__invoke($event); 217 | } 218 | }; 219 | }); 220 | } 221 | 222 | public function problemDetailsMiddleware(): ProblemDetailsMiddleware 223 | { 224 | return $this->makeSingleton(ProblemDetailsMiddleware::class, function() { 225 | $isDevEnvironment = $this->config->stringValue('environment', 'prod') === 'dev'; 226 | 227 | $problemDetailsResponseFactory = new class( 228 | function() { 229 | return new Response(); 230 | }, 231 | $isDevEnvironment 232 | ) extends ProblemDetailsResponseFactory { 233 | public function createResponseFromThrowable( 234 | ServerRequestInterface $request, 235 | \Throwable $e 236 | ) : ResponseInterface { 237 | if($e instanceof MessageDispatchException) { 238 | $e = $e->getPrevious(); 239 | } 240 | 241 | return parent::createResponseFromThrowable($request, $e); 242 | } 243 | }; 244 | 245 | $errorHandler = new ProblemDetailsMiddleware($problemDetailsResponseFactory); 246 | $errorHandler->attachListener(new PsrErrorLogger($this->logger())); 247 | 248 | return $errorHandler; 249 | }); 250 | } 251 | 252 | public function logger(): LoggerInterface 253 | { 254 | return $this->makeSingleton(LoggerInterface::class, function () { 255 | $streamHandler = new StreamHandler('php://stderr'); 256 | 257 | return new Logger('EventMachine', [$streamHandler]); 258 | }); 259 | } 260 | 261 | public function healthCheckResolver(): HealthCheckResolver 262 | { 263 | return $this->makeSingleton(HealthCheckResolver::class, function () { 264 | return new HealthCheckResolver(); 265 | }); 266 | } 267 | 268 | public function eventMachine(): EventMachine 269 | { 270 | $this->assertContainerIsset(); 271 | 272 | return $this->makeSingleton(EventMachine::class, function () { 273 | //@TODO add config param to enable caching 274 | $eventMachine = new EventMachine(); 275 | 276 | //Load descriptions here or add them to config/autoload/global.php 277 | foreach ($this->config->arrayValue('event_machine.descriptions') as $desc) { 278 | $eventMachine->load($desc); 279 | } 280 | 281 | $containerChain = new ContainerChain( 282 | $this->container, 283 | new EventMachineContainer($eventMachine) 284 | ); 285 | 286 | $eventMachine->initialize($containerChain); 287 | 288 | return $eventMachine; 289 | }); 290 | } 291 | 292 | private function assertContainerIsset(): void 293 | { 294 | if(null === $this->container) { 295 | throw new \RuntimeException("Main container is not set. Use " . __CLASS__ . "::setContainer() to set it."); 296 | } 297 | } 298 | 299 | private function assertMandatoryConfigExists(string $path): void 300 | { 301 | if(null === $this->config->mixedValue($path)) { 302 | throw new \RuntimeException("Missing application config for $path"); 303 | } 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /tests/BaseTestCase.php: -------------------------------------------------------------------------------- 1 | eventMachine = new EventMachine(); 30 | $this->flavour = new PrototypingFlavour(); 31 | 32 | $config = include __DIR__ . '/../config/autoload/global.php'; 33 | 34 | foreach ($config['event_machine']['descriptions'] as $description) { 35 | $this->eventMachine->load($description); 36 | } 37 | 38 | $this->eventMachine->initialize( 39 | new ContainerChain( 40 | new FlavourContainer($this->flavour), 41 | new EventMachineContainer($this->eventMachine) 42 | ) 43 | ); 44 | } 45 | 46 | protected function tearDown() 47 | { 48 | $this->eventMachine = null; 49 | } 50 | 51 | protected function message(string $msgName, array $payload = [], array $metadata = []): Message 52 | { 53 | return $this->eventMachine->messageFactory()->createMessageFromArray($msgName, [ 54 | 'payload' => $payload, 55 | 'metadata' => $metadata 56 | ]); 57 | } 58 | 59 | protected function assertRecordedEvent(string $eventName, array $payload, array $events, $assertNotRecorded = false): void 60 | { 61 | $isRecorded = false; 62 | 63 | foreach ($events as $evt) { 64 | if($evt === null) { 65 | continue; 66 | } 67 | 68 | [$evtName, $evtPayload] = $evt; 69 | 70 | if($eventName === $evtName) { 71 | $isRecorded = true; 72 | 73 | if(!$assertNotRecorded) { 74 | $this->assertEquals($payload, $evtPayload, "Payload of recorded event $evtName does not match with expected payload."); 75 | } 76 | } 77 | } 78 | 79 | if($assertNotRecorded) { 80 | $this->assertFalse($isRecorded, "Event $eventName is recorded"); 81 | } else { 82 | $this->assertTrue($isRecorded, "Event $eventName is not recorded"); 83 | } 84 | } 85 | 86 | protected function assertNotRecordedEvent(string $eventName, array $events): void 87 | { 88 | $this->assertRecordedEvent($eventName, [], $events, true); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/FlavourContainer.php: -------------------------------------------------------------------------------- 1 | flavour = $flavour; 22 | } 23 | 24 | /** 25 | * @inheritdoc 26 | */ 27 | public function get($id) 28 | { 29 | if($id === EventMachine::SERVICE_ID_FLAVOUR) { 30 | return $this->flavour; 31 | } 32 | 33 | throw ServiceNotFound::withServiceId($id); 34 | } 35 | 36 | /** 37 | * @inheritdoc 38 | */ 39 | public function has($id) 40 | { 41 | return $id === EventMachine::SERVICE_ID_FLAVOUR; 42 | } 43 | } 44 | --------------------------------------------------------------------------------