├── .gitignore
├── README.md
├── composer.json
├── phpunit.xml
├── src
├── DataConverter
│ └── ClassObjectConverter.php
├── DependencyInjection
│ ├── Configuration.php
│ └── TemporalExtension.php
├── Factory
│ └── ClientOptionsFactory.php
├── Pass
│ └── ActivityCompilerPass.php
├── Registry
│ └── ActivityRegistry.php
├── Resources
│ └── config
│ │ └── services.php
├── Serializer
│ ├── DefaultSerializerFactory.php
│ └── SerializerFactory.php
├── TemporalBundle.php
├── WorkflowClientFactory.php
├── WorkflowClientFactoryInterface.php
└── WorkflowRuntimeCommand.php
├── tests
├── .gitkeep
└── Factory
│ └── ClientOptionsFactoryTest.php
└── writing-tests.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | vendor/
3 | composer.lock
4 | .phpunit.result.cache
5 | .phpunit.cache/test-results
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Symfony Temporal Bundle
2 |
3 | ## Description
4 |
5 | This is a wrapper package for the [official PHP SDK](https://github.com/temporalio/sdk-php) with Activity Registry and full-configurable worker and workflow client.
6 |
7 | ## Table of Contents (Optional)
8 |
9 | If your README is long, add a table of contents to make it easy for users to find what they need.
10 |
11 | - [Installation](#installation)
12 | - [Usage](#usage)
13 | - [Writing PHPUnit tests](writing-tests.md)
14 | - [Credits](#credits)
15 | - [License](#license)
16 |
17 | ## Installation
18 |
19 | Use this command to install
20 | `composer require highcore/temporal-bundle`
21 |
22 | ## Usage
23 |
24 | Create config/workflows.php
25 |
26 | And register here your workflows, like a config/bundles.php for symfony
27 |
28 | Example config/workflows.php:
29 | ```php
30 | logger = new Logger();
176 | }
177 |
178 | public function upload(string $localFileName, string $url): void
179 | {
180 | if (!is_file($localFileName)) {
181 | throw new \InvalidArgumentException("Invalid file type: " . $localFileName);
182 | }
183 |
184 | // Faking upload to simplify sample implementation.
185 | $this->log('upload activity: uploaded from %s to %s', $localFileName, $url);
186 | }
187 |
188 | public function process(string $inputFileName): string
189 | {
190 | try {
191 | $this->log('process activity: sourceFile=%s', $inputFileName);
192 | $processedFile = $this->processFile($inputFileName);
193 | $this->log('process activity: processed file=%s', $processedFile);
194 |
195 | return $processedFile;
196 | } catch (\Throwable $e) {
197 | throw $e;
198 | }
199 | }
200 |
201 | public function download(string $url): TaskQueueFilenamePair
202 | {
203 | try {
204 | $this->log('download activity: downloading %s', $url);
205 |
206 | $data = file_get_contents($url);
207 | $file = tempnam(sys_get_temp_dir(), 'demo');
208 |
209 | file_put_contents($file, $data);
210 |
211 | $this->log('download activity: downloaded from %s to %s', $url, realpath($file));
212 |
213 | return new TaskQueueFilenamePair(self::$taskQueue, $file);
214 | } catch (\Throwable $e) {
215 | throw $e;
216 | }
217 | }
218 |
219 | private function processFile(string $filename): string
220 | {
221 | // faking processing for simplicity
222 | return $filename;
223 | }
224 |
225 | /**
226 | * @param string $message
227 | * @param mixed ...$arg
228 | */
229 | private function log(string $message, ...$arg)
230 | {
231 | // by default all error logs are forwarded to the application server log and docker log
232 | $this->logger->debug(sprintf($message, ...$arg));
233 | }
234 | }
235 | ```
236 |
237 | Example workflow interface:
238 | ```php
239 | defaultStoreActivities = Workflow::newActivityStub(
294 | StoreActivitiesInterface::class,
295 | ActivityOptions::new()
296 | ->withScheduleToCloseTimeout(CarbonInterval::minute(5))
297 | ->withTaskQueue(self::DEFAULT_TASK_QUEUE)
298 | );
299 | }
300 |
301 | public function processFile(string $sourceURL, string $destinationURL)
302 | {
303 | /** @var TaskQueueFilenamePair $downloaded */
304 | $downloaded = yield $this->defaultStoreActivities->download($sourceURL);
305 |
306 | $hostSpecificStore = Workflow::newActivityStub(
307 | StoreActivitiesInterface::class,
308 | ActivityOptions::new()
309 | ->withScheduleToCloseTimeout(CarbonInterval::minute(5))
310 | ->withTaskQueue($downloaded->hostTaskQueue)
311 | );
312 |
313 | // Call processFile activity to zip the file.
314 | // Call the activity to process the file using worker-specific task queue.
315 | $processed = yield $hostSpecificStore->process($downloaded->filename);
316 |
317 | // Call upload activity to upload the zipped file.
318 | yield $hostSpecificStore->upload($processed, $destinationURL);
319 |
320 | return 'OK';
321 | }
322 | }
323 | ```
324 |
325 | Register with symfony service container:
326 | ```php
327 | services();
331 | $services->defaults()
332 | ->public()
333 | ->autowire(true)
334 | ->autoconfigure(true);
335 |
336 | $services->set(Temporal\Samples\FileProcessing\StoreActivity::class)
337 | // Setting a "label to your activity" will add the activity to the ActivityRegistry,
338 | // allowing your employee to use this activity in your Workflow
339 | ->tag('temporal.activity.registry');
340 | ```
341 |
342 | Now you can run:
343 | ```bash
344 | rr serve rr.yaml
345 | ```
346 |
347 | And call workflow by:
348 | ```php
349 | workflowClient->newWorkflowStub(
369 | \Temporal\Samples\FileProcessing\FileProcessingWorkflowInterface::class,
370 | WorkflowOptions::new()
371 | ->withRetryOptions(
372 | RetryOptions::new()
373 | ->withMaximumAttempts(3)
374 | ->withNonRetryableExceptions(\LogicException::class)
375 | )
376 | );
377 |
378 | // Start Workflow async, with no-wait result
379 | /** @var WorkflowRunInterface $result */
380 | $result = $this->workflowClient->start($workflow, 'https://example.com/example_file', 's3://s3.example.com');
381 |
382 | echo 'Run ID: ' . $result->getExecution()->getRunID();
383 |
384 | // Or you can call workflow sync with wait result
385 | $result = $workflow->processingFile('https://example.com/example_file', 's3://s3.example.com');
386 |
387 | echo $result; // OK
388 | }
389 |
390 | }
391 | ```
392 |
393 | More php examples you can find [here](https://github.com/temporalio/samples-php)
394 |
395 | ## Credits
396 |
397 | - [Official Temporal PHP SDK](https://github.com/temporalio/sdk-php)
398 | - [Official Temporal PHP Samples](https://github.com/temporalio/samples-php)
399 | - [Symfony Framework](https://github.com/symfony/symfony)
400 |
401 | ## License
402 |
403 | MIT License
404 |
405 | Copyright (c) 2023 Highcore.org
406 |
407 | Permission is hereby granted, free of charge, to any person obtaining a copy
408 | of this software and associated documentation files (the "Software"), to deal
409 | in the Software without restriction, including without limitation the rights
410 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
411 | copies of the Software, and to permit persons to whom the Software is
412 | furnished to do so, subject to the following conditions:
413 |
414 | The above copyright notice and this permission notice shall be included in all
415 | copies or substantial portions of the Software.
416 |
417 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
418 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
419 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
420 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
421 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
422 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
423 | SOFTWARE.
424 |
425 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "highcore/temporal-bundle",
3 | "description": "Command Runner and Activity Registry for Temporal",
4 | "type": "library",
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "loper",
9 | "email": "bizrenay@gmail.com"
10 | }
11 | ],
12 | "minimum-stability": "stable",
13 | "require": {
14 | "php": ">=8.2",
15 | "ext-json": "*",
16 | "temporal/sdk": "^2.10",
17 | "symfony/dependency-injection": "^6.4 || ^7.0",
18 | "symfony/http-kernel": "^6.4 || ^7.0",
19 | "symfony/config": "^6.4 || ^7.0",
20 | "symfony/console": "^6.4 || ^7.0",
21 | "symfony/serializer": "^6.4 || ^7.0",
22 | "symfony/property-info": "^6.4 || ^7.0"
23 | },
24 | "require-dev": {
25 | "phpunit/phpunit": "^10.3"
26 | },
27 | "autoload": {
28 | "psr-4": {
29 | "Highcore\\TemporalBundle\\": "src/"
30 | }
31 | },
32 | "autoload-dev": {
33 | "psr-4": {
34 | "Tests\\Highcore\\TemporalBundle\\": "tests"
35 | }
36 | },
37 | "conflict": {
38 | "symfony/symfony": "*"
39 | },
40 | "config": {
41 | "allow-plugins": {
42 | "symfony/flex": true
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 | ./tests
12 |
13 |
14 |
15 |
16 | ./src
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/DataConverter/ClassObjectConverter.php:
--------------------------------------------------------------------------------
1 | create($this->getSerializer()->serialize($value, 'json'));
37 | }
38 |
39 | public function fromPayload(Payload $payload, Type $type)
40 | {
41 | if (!$type->isClass()) {
42 | throw new DataConverterException('Unable to decode value using class object converter - ');
43 | }
44 |
45 | $dataToHydrate = json_decode($payload->getData(), true, 512, JSON_THROW_ON_ERROR);
46 |
47 | return $this->getSerializer()->denormalize($dataToHydrate, $type->getName());
48 | }
49 |
50 | private function getSerializer(): Serializer
51 | {
52 | return $this->serializer ?? ($this->serializer = $this->serializerFactory->create());
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Configuration.php:
--------------------------------------------------------------------------------
1 | getRootNode()
22 | ->children()
23 | ->scalarNode('address')->defaultValue('localhost:7233')->end()
24 | ->arrayNode('worker')
25 | ->children()
26 | ->scalarNode('queue')->defaultValue('default')->end()
27 | ->variableNode('factory')->defaultValue(WorkerFactory::class)->end()
28 | ->arrayNode('data_converter')
29 | ->children()
30 | ->arrayNode('converters')
31 | ->requiresAtLeastOneElement()
32 | ->useAttributeAsKey('name')
33 | ->prototype('scalar')->end()
34 | ->end()
35 | ->scalarNode('class')->defaultValue(DataConverter::class)->end()
36 | ->end()
37 | ->end()
38 | ->arrayNode('testing')
39 | ->addDefaultsIfNotSet()
40 | ->children()
41 | ->booleanNode('enabled')->defaultFalse()->end()
42 | ->scalarNode('activity_invocation_cache')->defaultValue(InMemoryActivityInvocationCache::class)->end()
43 | ->end()
44 | ->end()
45 | ->end()
46 | ->end()
47 | ->arrayNode('workflow_client')
48 | ->children()
49 | ->arrayNode('options')
50 | ->children()
51 | ->scalarNode('namespace')->defaultValue('default')->end()
52 | ->scalarNode('identity')->example('pid@host')->end()
53 | ->enumNode('query_rejection_condition')
54 | ->values([
55 | QueryRejectCondition::QUERY_REJECT_CONDITION_NONE,
56 | QueryRejectCondition::QUERY_REJECT_CONDITION_UNSPECIFIED,
57 | QueryRejectCondition::QUERY_REJECT_CONDITION_NOT_OPEN,
58 | QueryRejectCondition::QUERY_REJECT_CONDITION_NOT_COMPLETED_CLEANLY,
59 | ])
60 | ->defaultValue(QueryRejectCondition::QUERY_REJECT_CONDITION_NONE)
61 | ->end()
62 | ->end()
63 | ->end()
64 | ->scalarNode('factory')->defaultValue(WorkflowClientFactory::class)->end()
65 | ->end()
66 | ->end()
67 | ->end()
68 | ;
69 |
70 | return $treeBuilder;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/DependencyInjection/TemporalExtension.php:
--------------------------------------------------------------------------------
1 | setParameter(
98 | 'temporal.worker.queue',
99 | $this->extractWorkerQueue($config, $defaultQueue),
100 | );
101 | }
102 |
103 | private function registerTemporalAddressParameter(
104 | array $config,
105 | ContainerBuilder $container,
106 | ): void {
107 | $container->setParameter(
108 | 'temporal.address',
109 | $this->extractTemporalAddress($config),
110 | );
111 | }
112 |
113 | private function registerTemporalNamespaceParameter(
114 | array $config,
115 | ContainerBuilder $container,
116 | string $defaultNamespace = 'default',
117 | ): void {
118 | $container->setParameter(
119 | 'temporal.namespace',
120 | $this->extractTemporalNamespace($config, $defaultNamespace),
121 | );
122 | }
123 |
124 | private function registerDataConverterService(
125 | array $config,
126 | ContainerBuilder $container,
127 | string $defaultDataConverterFacadeClass = DataConverter::class,
128 | ): Definition {
129 | $dataConverterFacadeClass = $this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass);
130 | if ($container->hasDefinition($dataConverterFacadeClass)) {
131 | return $container->getDefinition($dataConverterFacadeClass);
132 | }
133 |
134 | return $container->register($dataConverterFacadeClass)
135 | ->setArguments(array_map(function ($dataConverterDelegateClass) use ($container): Reference {
136 | if (class_exists($dataConverterDelegateClass) && !$container->hasDefinition($dataConverterDelegateClass)) {
137 | $container->register($dataConverterDelegateClass)
138 | ->setAutoconfigured(true)
139 | ->setAutowired(true)
140 | ;
141 | }
142 |
143 | return new Reference($dataConverterDelegateClass);
144 | }, $this->extractDataConverterClasses($config)));
145 | }
146 |
147 | private function registerDataConverterAlias(
148 | array $config,
149 | ContainerBuilder $container,
150 | string $defaultDataConverterFacadeClass = DataConverter::class,
151 | ): Alias {
152 | if ($container->hasAlias(DataConverterInterface::class)) {
153 | return $container->getAlias(DataConverterInterface::class);
154 | }
155 |
156 | return $container->setAlias(DataConverterInterface::class, $this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass));
157 | }
158 |
159 | private function registerWorkerFactoryService(
160 | array $config,
161 | ContainerBuilder $container,
162 | string $defaultWorkerFactoryClass = WorkerFactory::class,
163 | string $defaultDataConverterFacadeClass = DataConverter::class,
164 | string $defaultActivityInvocationCacheClass = InMemoryActivityInvocationCache::class,
165 | ): Definition {
166 | $workerFactoryClass = $this->extractWorkerFactoryClass($config, $defaultWorkerFactoryClass);
167 | if ($container->hasDefinition($workerFactoryClass)) {
168 | return $container->getDefinition($workerFactoryClass);
169 | }
170 |
171 | if ($this->isTestingEnabled($config)) {
172 | return $container->register($workerFactoryClass)
173 | ->setPublic(true)
174 | ->setAutowired(true)
175 | ->setAutoconfigured(true)
176 | ->setFactory("{$workerFactoryClass}::create")
177 | ->setArgument('$converter', new Reference($this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass)))
178 | ->setArgument('$activityCache', new Reference($this->extractActivityInvocationCacheClass($config, $defaultActivityInvocationCacheClass)))
179 | ;
180 | }
181 |
182 | return $container->register($workerFactoryClass)
183 | ->setPublic(true)
184 | ->setAutowired(true)
185 | ->setAutoconfigured(true)
186 | ->setFactory("{$workerFactoryClass}::create")
187 | ->setArgument('$converter', new Reference($this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass)))
188 | ;
189 | }
190 |
191 | private function registerWorkerFactoryAlias(
192 | array $config,
193 | ContainerBuilder $container,
194 | string $defaultWorkerFactoryClass = WorkerFactory::class,
195 | ): Alias {
196 | $workerFactoryClass = $this->extractWorkerFactoryClass($config, $defaultWorkerFactoryClass);
197 | if ($container->hasDefinition($workerFactoryClass)) {
198 | return $container->setAlias(WorkerFactoryInterface::class, $workerFactoryClass);
199 | }
200 |
201 | if ($this->isTestingEnabled($config)) {
202 | return $container->setAlias(WorkerFactoryInterface::class, TestingWorkerFactory::class);
203 | }
204 |
205 | return $container->setAlias(WorkerFactoryInterface::class, WorkerFactory::class);
206 | }
207 |
208 | private function registerActivityInvocationCacheService(
209 | array $config,
210 | ContainerBuilder $container,
211 | string $defaultActivityInvocationCacheClass = InMemoryActivityInvocationCache::class,
212 | string $defaultDataConverterFacadeClass = DataConverter::class,
213 | ): Definition {
214 | return match ($this->extractActivityInvocationCacheClass($config, $defaultActivityInvocationCacheClass)) {
215 | RoadRunnerActivityInvocationCache::class => $this->registerRoadRunnerActivityInvocationCacheService($config, $container, $defaultDataConverterFacadeClass),
216 | InMemoryActivityInvocationCache::class => $this->registerInMemoryActivityInvocationCacheService($config, $container, $defaultDataConverterFacadeClass),
217 | default => $container->getDefinition($this->extractActivityInvocationCacheClass($config, $defaultActivityInvocationCacheClass)),
218 | };
219 | }
220 |
221 | private function registerRoadRunnerActivityInvocationCacheService(
222 | array $config,
223 | ContainerBuilder $container,
224 | string $defaultDataConverterFacadeClass = DataConverter::class,
225 | ): Definition {
226 | if ($container->hasDefinition(RoadRunnerActivityInvocationCache::class)) {
227 | return $container->getDefinition(RoadRunnerActivityInvocationCache::class);
228 | }
229 |
230 | return $container->register(RoadRunnerActivityInvocationCache::class)
231 | ->setFactory(sprintf('%s::create', RoadRunnerActivityInvocationCache::class))
232 | ->setArgument('$dataConverter', new Reference($this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass)))
233 | ;
234 | }
235 |
236 | private function registerInMemoryActivityInvocationCacheService(
237 | array $config,
238 | ContainerBuilder $container,
239 | string $defaultDataConverterFacadeClass = DataConverter::class,
240 | ): Definition {
241 | if ($container->hasDefinition(InMemoryActivityInvocationCache::class)) {
242 | return $container->getDefinition(InMemoryActivityInvocationCache::class);
243 | }
244 |
245 | return $container->register(InMemoryActivityInvocationCache::class)
246 | ->setArgument('$dataConverter', new Reference($this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass)))
247 | ;
248 | }
249 |
250 | private function registerActivityInvocationCacheAlias(
251 | array $config,
252 | ContainerBuilder $container,
253 | string $defaultActivityInvocationCacheClass = InMemoryActivityInvocationCache::class,
254 | ): Alias {
255 | $activityInvocationCacheClass = $this->extractActivityInvocationCacheClass($config, $defaultActivityInvocationCacheClass);
256 |
257 | return $container->setAlias(ActivityInvocationCacheInterface::class, new Alias($activityInvocationCacheClass));
258 | }
259 |
260 | private function registerWorkflowClientFactoryService(
261 | array $config,
262 | ContainerBuilder $container,
263 | string $defaultWorkflowClientFactoryClass = WorkflowClientFactory::class,
264 | string $defaultDataConverterFacadeClass = DataConverter::class,
265 | ): Definition {
266 | $workflowClientFactoryClass = $this->extractWorkflowClientFactoryClass($config, $defaultWorkflowClientFactoryClass);
267 |
268 | if ($container->hasDefinition($workflowClientFactoryClass)) {
269 | return $container->getDefinition($workflowClientFactoryClass);
270 | }
271 |
272 | return $container->register($workflowClientFactoryClass)
273 | ->setPublic(true)
274 | ->setAutowired(true)
275 | ->setAutoconfigured(true)
276 | ->addMethodCall('setDataConverter', [new Reference($this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass))])
277 | ->addMethodCall('setAddress', [$this->extractTemporalAddress($config)])
278 | ->addMethodCall('setOptions', [$this->extractWorkflowClientOptions($config)])
279 | ;
280 | }
281 |
282 | private function registerWorkflowClientService(
283 | array $config,
284 | ContainerBuilder $container,
285 | string $defaultWorkflowClientFactoryClass = WorkflowClientFactory::class,
286 | ): Definition {
287 | return $container->register(WorkflowClient::class)
288 | ->setFactory(new Reference($this->extractWorkflowClientFactoryClass($config, $defaultWorkflowClientFactoryClass)));
289 | }
290 |
291 | public function load(array $configs, ContainerBuilder $container): void
292 | {
293 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
294 | $loader->load('services.php');
295 |
296 | $config = $this->processConfiguration(new Configuration(), $configs);
297 |
298 | $options = $config['workflow_client']['options'] ?? [];
299 |
300 | $this->registerWorkerQueueParameter($config, $container);
301 | $this->registerTemporalAddressParameter($config, $container);
302 | $this->registerTemporalNamespaceParameter($config, $container);
303 |
304 | $this->registerDataConverterService($config, $container);
305 | $this->registerDataConverterAlias($config, $container);
306 | $this->registerWorkflowClientFactoryService($config, $container);
307 | $this->registerWorkflowClientService($config, $container);
308 |
309 | if ($this->isTestingEnabled($config)) {
310 | $this->registerActivityInvocationCacheService($config, $container);
311 | $this->registerActivityInvocationCacheAlias($config, $container);
312 | }
313 |
314 | $this->registerWorkerFactoryService($config, $container);
315 | $this->registerWorkerFactoryAlias($config, $container);
316 | }
317 | }
318 |
--------------------------------------------------------------------------------
/src/Factory/ClientOptionsFactory.php:
--------------------------------------------------------------------------------
1 | withNamespace($options['namespace']);
17 | }
18 |
19 | if (isset($options['identity'])) {
20 | $clientOptions = $clientOptions->withIdentity($options['identity']);
21 | }
22 |
23 | if (isset($options['query-rejection-condition'])) {
24 | $clientOptions = $clientOptions->withQueryRejectionCondition($options['query-rejection-condition']);
25 | }
26 |
27 | return $clientOptions;
28 | }
29 | }
--------------------------------------------------------------------------------
/src/Pass/ActivityCompilerPass.php:
--------------------------------------------------------------------------------
1 | has(ActivityRegistry::class)) {
17 | return;
18 | }
19 |
20 | $definition = $container->findDefinition(ActivityRegistry::class);
21 | $taggedServices = $container->findTaggedServiceIds('temporal.activity.registry');
22 |
23 | foreach ($taggedServices as $id => $tags) {
24 | $definition->addMethodCall('add', [new Reference($id)]);
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/src/Registry/ActivityRegistry.php:
--------------------------------------------------------------------------------
1 | isActivity($reflection)) {
19 | throw new \LogicException(\sprintf(
20 | 'Class "%s" does not have "%s" or "%s" attribute.',
21 | $activity::class, ActivityInterface::class, LocalActivityInterface::class));
22 | }
23 |
24 | $this->activities[] = $activity;
25 | }
26 |
27 | public function all(): array
28 | {
29 | return $this->activities;
30 | }
31 |
32 | private function isActivity(\ReflectionObject $reflection): bool
33 | {
34 | if (\count($reflection->getAttributes(ActivityInterface::class)) >= 1
35 | || \count($reflection->getAttributes(LocalActivityInterface::class)) >= 1
36 | ) {
37 | return true;
38 | }
39 |
40 | foreach ($reflection->getInterfaces() as $interface) {
41 | if (\count($interface->getAttributes(ActivityInterface::class)) >= 1
42 | || \count($interface->getAttributes(LocalActivityInterface::class)) >= 1
43 | ) {
44 | return true;
45 | }
46 | }
47 |
48 | return false;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Resources/config/services.php:
--------------------------------------------------------------------------------
1 | services();
8 | $services
9 | ->defaults()
10 | ->autowire()
11 | ->autoconfigure();
12 |
13 | $services->set(Highcore\TemporalBundle\Registry\ActivityRegistry::class);
14 | $services->set(Highcore\TemporalBundle\WorkflowRuntimeCommand::class)
15 | ->arg('$activityRegistry', service(Highcore\TemporalBundle\Registry\ActivityRegistry::class))
16 | ->arg('$workerFactory', service(Temporal\Worker\WorkerFactoryInterface::class))
17 | ->arg('$workerQueue', '%temporal.worker.queue%')
18 | ->arg('$kernel', service('kernel'))
19 | ->tag('console.command');
20 |
21 | $services->alias(Temporal\Client\WorkflowClientInterface::class, Temporal\Client\WorkflowClient::class);
22 | };
23 |
--------------------------------------------------------------------------------
/src/Serializer/DefaultSerializerFactory.php:
--------------------------------------------------------------------------------
1 | new JsonEncoder()]
31 | );
32 | }
33 | }
--------------------------------------------------------------------------------
/src/Serializer/SerializerFactory.php:
--------------------------------------------------------------------------------
1 | addCompilerPass(new ActivityCompilerPass());
16 | }
17 | }
--------------------------------------------------------------------------------
/src/WorkflowClientFactory.php:
--------------------------------------------------------------------------------
1 | address),
24 | options: $this->options,
25 | converter: $this->dataConverter
26 | );
27 | }
28 |
29 | public function setOptions(array $options): void
30 | {
31 | $this->options = (new ClientOptionsFactory())->createFromArray($options);
32 | }
33 |
34 | public function setAddress(string $address): void
35 | {
36 | $this->address = $address;
37 | }
38 |
39 | public function setDataConverter(DataConverterInterface $converter): void
40 | {
41 | $this->dataConverter = $converter;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/WorkflowClientFactoryInterface.php:
--------------------------------------------------------------------------------
1 | kernel = $kernel;
32 | $this->workerQueue = $workerQueue;
33 | $this->activityRegistry = $activityRegistry;
34 | $this->workerFactory = $workerFactory;
35 | }
36 |
37 | protected function execute(InputInterface $input, OutputInterface $output): int
38 | {
39 | $style = new SymfonyStyle($input, $output);
40 |
41 | if ('' === $this->workerQueue) {
42 | $style->error(\sprintf('Worker queue name "%s" is not valid.', $this->workerQueue));
43 | return Command::FAILURE;
44 | }
45 |
46 | $queueName = $this->workerQueue ?? WorkerFactoryInterface::DEFAULT_TASK_QUEUE;
47 |
48 |
49 | $worker = $this->workerFactory->newWorker($queueName);
50 |
51 | foreach ($this->getWorkflowTypes() as $workflowType) {
52 | $worker->registerWorkflowTypes($workflowType);
53 | }
54 |
55 | foreach ($this->activityRegistry->all() as $activity) {
56 | $worker->registerActivity(get_class($activity), static fn() => $activity);
57 | }
58 |
59 |
60 | $this->workerFactory->run();
61 |
62 | return Command::SUCCESS;
63 | }
64 |
65 | private function getWorkflowTypes(): array
66 | {
67 | $workflowTypesConfig = $this->kernel->getProjectDir() . '/config/workflows.php';
68 |
69 | if (!\file_exists($workflowTypesConfig)) {
70 | return [];
71 | }
72 |
73 | $workflowTypes = require $workflowTypesConfig;
74 |
75 | if (!\is_array($workflowTypes)) {
76 | throw new \RuntimeException('Workflow config should return array.');
77 | }
78 |
79 | return $workflowTypes;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/tests/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/highcoreorg/temporal-bundle/a47c0a2d1d16b23b1c0bee6755da4e33eecbc03c/tests/.gitkeep
--------------------------------------------------------------------------------
/tests/Factory/ClientOptionsFactoryTest.php:
--------------------------------------------------------------------------------
1 | createFromArray([
20 | 'namespace' => $namespace,
21 | 'identity' => $identity,
22 | 'query-rejection-condition' => $queryRejectionCondition,
23 | ]);
24 |
25 | $this->assertEquals($namespace, $options->namespace);
26 | $this->assertEquals($identity, $options->identity);
27 | $this->assertEquals($queryRejectionCondition, $options->queryRejectionCondition);
28 | }
29 |
30 | public static function data(): array
31 | {
32 | return [
33 | ['namespace', 'identity', 123],
34 | ['', 'another', 456],
35 | ];
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/writing-tests.md:
--------------------------------------------------------------------------------
1 | Writing Tests
2 | ===
3 |
4 | Configuration
5 | ---
6 |
7 | In order to enable the testing functionality, it is required to make some changes to the configuration and follow a few
8 | steps from the Temporal SDK documentation.
9 |
10 | First of all, you will have to add configuration to your app, with adding a `config/packages/test/temporal.yaml` file
11 | inside your project, with the following contents:
12 |
13 | ```yaml
14 | temporal:
15 | worker:
16 | factory: Temporal\Testing\WorkerFactory
17 |
18 | testing:
19 | enabled: true
20 | activity_invocation_cache: Temporal\Worker\ActivityInvocationCache\RoadRunnerActivityInvocationCache
21 | ```
22 |
23 | ### Creating a testing environment
24 |
25 | The Temporal SDK documentation does not provide some extensive documentation about how to configure your testing
26 | environment. Some generic examples are provided, but you will usually need to dig into the internals to figure out the
27 | proper way to do it.
28 |
29 | Here is a simplified checklist in the context of a Symfony or API Platform project using this bundle:
30 |
31 | 1. Install PHPUnit in your preferred version, or install the latest with `composer require phpunit/phpunit`
32 | 2. Make sure your PHPUnit configuration is providing a bootstrap file, as described in the documentation [ [1](https://symfony.com/doc/current/testing/bootstrap.html) ] and [ [2](https://docs.phpunit.de/en/10.5/configuration.html#the-bootstrap-attribute) ]
33 | ```xml
34 |
35 |
36 |
39 |
40 |
41 | ```
42 | 3. In your bootstrap file lcated in `tests/bootstrap.php` you should have the following content:
43 | ```php
44 | bootEnv(dirname(__DIR__).'/.env');
52 | }
53 |
54 | if ($_SERVER['APP_DEBUG']) {
55 | umask(0000);
56 | }
57 |
58 | $environment = \Temporal\Testing\Environment::create();
59 | // The ./rr file is created after running installation through composer
60 | // see https://github.com/roadrunner-server/roadrunner?tab=readme-ov-file#installation-via-composer
61 | // the Temporal\Testing\Environment class does not support other ways of installation
62 | //$environment->start('./rr serve -w /app -c .rr.yaml');
63 | $environment->start('./rr serve -c tests/.rr.test.yaml -w /app');
64 | register_shutdown_function(fn () => $environment->stop());
65 | ```
66 | 4. You will then need to create a `tests/.rr.test.yaml` file with the following contents:
67 | ```yaml
68 | version: '3'
69 |
70 | server:
71 | command: "bin/console temporal:workflow:runtime --env=test"
72 | # user: "backend" # Set up your user, or remove this value
73 | # group: "backend" # Set up your group, or remove this value
74 |
75 | temporal:
76 | address: "${TEMPORAL_TEST_ADDRESS:-127.0.0.1:7233}" # It is important to let 127.0.0.1 here, as you will use the testing server, launched by the Temporal\Testing\Environment class.
77 | namespace: "${TEMPORAL_TEST_WORKER_QUEUE:-default}" # Configure a temporal namespace (you must create a namespace manually or use the default namespace named "default")
78 | activities:
79 | num_workers: 1 # Set up your worker count
80 |
81 | # Set up your values
82 | logs:
83 | mode: development
84 | output: stdout
85 | err_output: stderr
86 | encoding: console
87 | level: debug
88 |
89 |
90 | rpc:
91 | listen: 'tcp://127.0.0.1:6001'
92 |
93 | kv:
94 | test:
95 | driver: memory
96 | config:
97 | interval: 10
98 | ```
99 |
100 | You will then have a complete testing environment.
101 |
102 | Writing your first PHPUnit test with Temporal
103 | ---
104 |
105 | ### Activity and Workflow examples
106 |
107 | Let's assume you have an Activity declared in the `App\SimpleActivity` class as follows:
108 |
109 | ```php
110 | */
172 | private ActivityProxy $activities;
173 |
174 | public function __construct()
175 | {
176 | $this->activities = Workflow::newActivityStub(
177 | SimpleActivityInterface::class,
178 | ActivityOptions::new()
179 | ->withStartToCloseTimeout(CarbonInterval::minutes(15))
180 | // disable retries for example to run faster
181 | ->withRetryOptions(
182 | RetryOptions::new()
183 | ->withMaximumAttempts(5)
184 | ->withInitialInterval(10)
185 | )
186 | );
187 | }
188 |
189 | #[WorkflowMethod(name: 'greeting')]
190 | public function greeting(string $name): \Generator
191 | {
192 | return yield $this->activities->greeting($name);
193 | }
194 | }
195 | ```
196 |
197 | ### How to write a PHPUnit test for your Workflow
198 |
199 | In order to test your workflow, the preferred method is to mock every activity. The reason you should do this is to make
200 | your unit tests isolated from any I/O or other service that could mess with the results of your tests (Database,
201 | Network, Files, etc.). Also, this is to prevent tests from interacting with each other, each test should be run
202 | independently and should not interact with other test.
203 |
204 | This is the reasons why it is preferred to use a generic `PHPUnit\Framework\TestCase` provided by PHPUnit than the
205 | `Symfony\Bundle\FrameworkBundle\Test\KernelTestCase` provided by Symfony.
206 |
207 | However, it is not always possible. eg. if you have integration tests requiring access to a database and some data
208 | fixtures. In those cases, you will be required to keep in mind that a Temporal environment requires 2 PHP processes and
209 | those 2 processes needs to access to consistent data between those processes.
210 |
211 | Here is an example to test the `App\SimpleWorkflow` Workflow class with a generic test:
212 |
213 | ```php
214 | workflowClient = new WorkflowClient(ServiceClient::create('127.0.0.1:7233'));
236 | $this->activityMocks = new ActivityMocker();
237 |
238 | parent::setUp();
239 | }
240 |
241 | protected function tearDown(): void
242 | {
243 | $this->activityMocks->clear();
244 | parent::tearDown();
245 | }
246 |
247 | public function testWorkflowReturnsUpperCasedInput(): void
248 | {
249 | $this->activityMocks->expectCompletion('SimpleActivity.greeting', 'hello');
250 | $workflow = $this->workflowClient->newWorkflowStub(SimpleWorkflow::class);
251 |
252 | $run = $this->workflowClient->start($workflow, 'hello');
253 |
254 | $this->assertSame('hello', $run->getResult('string'));
255 | }
256 | }
257 | ```
258 |
259 | References
260 | ---
261 |
262 | * [highcoreorg/temporal-bundle#12](https://github.com/highcoreorg/temporal-bundle/pull/12)
263 | * [PHPUnit with Symfony](https://symfony.com/doc/current/testing/bootstrap.html)
264 | * [PHPUnit bootstrap attribute](https://docs.phpunit.de/en/10.5/configuration.html#the-bootstrap-attribute)
--------------------------------------------------------------------------------