How to Create a New Connector

Sometimes native connectors won’t cover all your needs, you will need to write your own.

A connector is an ensemble of jobs able to import and export data using a specific format. Each job is composed of several steps.

Note

For more details about these concepts, see import/export Main Concepts.

Let’s say we want to create a connector that can export CSV data (like the native one), but at the end of each export we want to notify another application. We also want the notification to contain the path to the directory of the exported file.

Note

Here we use a very simple case to have overview of the connectors, for more complex cases (like adding support for new file formats) you can refer to the next chapters.

Configure a job

Jobs and steps are actually Symfony services. The first thing we need is to declare a new service for our product export job:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
parameters:
    acme_notifyconnector.connector_name.csv: Acme CSV Notify Connector
    acme_notifyconnector.job_name.csv_product_export_notify: 'csv_product_export_notify'

services:
    acme_notifyconnector.csv_product_export_notify:
        class: '%pim_connector.job.simple_job.class%'
        arguments:
            - '%acme_notifyconnector.job_name.csv_product_export_notify%'
            - '@event_dispatcher'
            - '@akeneo_batch.job_repository'
            -
                - '@acme_notifyconnector.step.notify'
        tags:
            - { name: akeneo_batch.job, connector: '%acme_notifyconnector.connector_name.csv%', type: '%pim_connector.job.export_type%' }

Warning

Make sure that the file containing your declaration is correctly loaded by your bundle extension. For more info please see the Symfony documentation.

Please note that in versions < 1.6, the file was named batch_jobs.yml and was automatically loaded. The file content was very strict, was less standard and upgradeable than it is now.

As you can see there is almost no difference with the native CSV export job. The only new info here is the name (first parameter) and the connector name (the connector property of the tag).

How can we add our notification behaviour to this job? The simplest way is to write a new step that will be executed after the export step.

Add a new step

A step class needs two things: extend Akeneo\Component\Batch\Step\AbstractStep and implement a doExecute() method. This method will contain your custom behavior:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php

namespace Acme\Bundle\NotifyConnectorBundle\Step;

use Akeneo\Component\Batch\Step\AbstractStep;
use Akeneo\Component\Batch\Model\StepExecution;

class NotifyStep extends AbstractStep
{
    protected function doExecute(StepExecution $stepExecution)
    {
        // inject the step execution in the step item to be able to log summary info during execution
        $jobParameters = $stepExecution->getJobParameters();

        $directory = dirname($jobParameters->get('filePath'));
        $fields = sprintf('directory=%s', urlencode($directory));
        $url = $jobParameters->get('urlToNotify');

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $fields);

        if (false !== curl_exec($ch)) {
            $stepExecution->addSummaryInfo('notified', 'yes');
        } else {
            $stepExecution->addSummaryInfo('notified', 'no');
            $stepExecution->addError('Failed to call target URL: '.curl_error($ch));
        }

        curl_close($ch);
    }
}

We can now declare the step as a service:

1
2
3
4
5
6
7
services:
    acme_notifyconnector.step.notify:
        class: 'Acme\Bundle\NotifyConnectorBundle\Step\NotifyStep'
        arguments:
            - 'notify'
            - '@event_dispatcher'
            - '@akeneo_batch.job_repository'

And add it to the job we previously declared:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
parameters:
    acme_notifyconnector.connector_name.csv: Acme CSV Notify Connector
    acme_notifyconnector.job_name.csv_product_export_notify: 'csv_product_export_notify'

services:
    acme_notifyconnector.csv_product_export_notify:
        class: '%pim_connector.job.simple_job.class%'
        arguments:
            - '%acme_notifyconnector.job_name.csv_product_export_notify%'
            - '@event_dispatcher'
            - '@akeneo_batch.job_repository'
            -
                - '@pim_connector.step.csv_product.export'
                - '@acme_notifyconnector.step.notify'
        tags:
            - { name: akeneo_batch.job, connector: '%acme_notifyconnector.connector_name.csv%', type: '%pim_connector.job.export_type%' }

Tip

Thanks to Symfony’s dependency injection, it’s quite easy to reuse a step for several jobs. For example, our notification step can be added to any export job just by putting it in the job service declaration.

Configure a job instance

A job can be seen as a template, it cannot be executed on its own: it needs parameters. For example our new job needs filePath and urlToNotify parameters to work properly (plus the ones needed by the native export step).

Each set of parameters for a given job is called a job instance. A job instance can be executed, modified or deleted using the UI or the akeneo:batch:* Symfony commands.

A job also needs a way to get default values for parameters and a way to validate this parameters.

Let’s write it! For convenience reasons we can use the same class for both roles, it must then implement both Akeneo\Component\Batch\Job\JobParameters\DefaultValuesProviderInterface and Akeneo\Component\Batch\Job\JobParameters\ConstraintCollectionProviderInterface.

We want also to keep the default values and validation constraints needed by the native export step. The easiest way to do that is to use the decoration pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?php

namespace Acme\Bundle\NotifyConnectorBundle\JobParameters;

use Akeneo\Component\Batch\Job\JobInterface;
use Akeneo\Component\Batch\Job\JobParameters\ConstraintCollectionProviderInterface;
use Akeneo\Component\Batch\Job\JobParameters\DefaultValuesProviderInterface;
use Symfony\Component\Validator\Constraints\Collection;
use Symfony\Component\Validator\Constraints\Url;

class ProductCsvExportNotify implements
    ConstraintCollectionProviderInterface,
    DefaultValuesProviderInterface
{
    /** @var DefaultValuesProviderInterface */
    private $baseDefaultValuesProvider;

    /** @var ConstraintCollectionProviderInterface */
    private $baseConstraintCollectionProvider;

    /** @var string[] */
    private $supportedJobNames;

    /**
     * @param DefaultValuesProviderInterface        $baseDefaultValuesProvider
     * @param ConstraintCollectionProviderInterface $baseConstraintCollectionProvider
     * @param string[]                              $supportedJobNames
     */
    public function __construct(
        DefaultValuesProviderInterface $baseDefaultValuesProvider,
        ConstraintCollectionProviderInterface $baseConstraintCollectionProvider,
        array $supportedJobNames
    ) {
        $this->baseDefaultValuesProvider = $baseDefaultValuesProvider;
        $this->baseConstraintCollectionProvider = $baseConstraintCollectionProvider;
        $this->supportedJobNames = $supportedJobNames;
    }

    /**
     * {@inheritdoc}
     */
    public function getDefaultValues()
    {
        return array_merge(
            $this->baseDefaultValuesProvider->getDefaultValues(),
            ['urlToNotify' => 'http://']
        );
    }

    /**
     * {@inheritdoc}
     */
    public function getConstraintCollection()
    {
        $baseConstraints = $this->baseConstraintCollectionProvider->getConstraintCollection();
        $constraintFields = array_merge(
            $baseConstraints->fields,
            ['urlToNotify' => new Url()]
        );

        return new Collection(['fields' => $constraintFields]);
    }

    /**
     * {@inheritdoc}
     */
    public function supports(JobInterface $job)
    {
        return in_array($job->getName(), $this->supportedJobNames);
    }
}

Tip

If the job doesn’t need any particular parameters, it’s possible to use directly the classes Akeneo\Component\Batch\Job\JobParameters\EmptyDefaultValuesProvider and Akeneo\Component\Batch\Job\JobParameters\EmptyConstraintCollectionProvider.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
services:
    acme_notifyconnector.job.job_parameters.csv_product_export_notify:
        class: 'Acme\Bundle\NotifyConnectorBundle\JobParameters\ProductCsvExportNotify'
        arguments:
            - '@pim_connector.job.job_parameters.default_values_provider.product_csv_export'
            - '@pim_connector.job.job_parameters.constraint_collection_provider.product_csv_export'
            - ['%acme_notifyconnector.job_name.csv_product_export_notify%']
        tags:
            - { name: akeneo_batch.job.job_parameters.constraint_collection_provider }
            - { name: akeneo_batch.job.job_parameters.default_values_provider }

Your job instances parameters can now be populated by default and validated.

Create a job instance via the command

We can create an instance with the following command:

# akeneo:batch:create-job <connector> <job> <type> <code> <config> [<label>]
php app/console akeneo:batch:create-job 'Acme CSV Notify Connector' csv_product_export_notify export my_app_product_export '{"urlToNotify": "http://my-app.com/product-export-done"}'

You can also list the existing job instances with the following command:

php app/console akeneo:batch:list-jobs

Execute our new job instance

You can run the job with the following command:

php app/console akeneo:batch:job my_app_product_export

[2017-04-18 18:43:55] batch.DEBUG: Job execution starting: startTime=, endTime=, updatedTime=, status=2, exitStatus=[UNKNOWN] , exitDescription=[], job=[my_app_product_export] [] []
[2017-04-18 18:43:55] batch.INFO: Step execution starting: id=0, name=[export], status=[2], exitCode=[EXECUTING], exitDescription=[] [] []
[2017-04-18 18:43:55] batch.DEBUG: Step execution success: id= 42 [] []
[2017-04-18 18:43:55] batch.DEBUG: Step execution complete: id=42, name=[export], status=[1], exitCode=[EXECUTING], exitDescription=[] [] []
[2017-04-18 18:43:55] batch.INFO: Step execution starting: id=0, name=[notify], status=[2], exitCode=[EXECUTING], exitDescription=[] [] []
[2017-04-18 18:43:55] batch.DEBUG: Step execution success: id= 43 [] []
[2017-04-18 18:43:55] batch.DEBUG: Step execution complete: id=43, name=[notify], status=[1], exitCode=[EXECUTING], exitDescription=[] [] []
[2017-04-18 18:43:55] batch.DEBUG: Upgrading JobExecution status: startTime=2017-04-18T16:43:55+00:00, endTime=, updatedTime=, status=3, exitStatus=[UNKNOWN] , exitDescription=[], job=[my_app_product_export] [] []
Export my_app_product_export has been successfully executed.

The --config option can be used to override the job instance parameters at runtime, for instance, to change the file path:

php app/console akeneo:batch:job my_app_product_export --config='{"filePath":"\/tmp\/new_path.csv"}'

Configure the UI for our new job

At this point the job instance is usable in command line, but it cannot be configured via the UI.

Like most of UI parts in the application now, the job instance forms are made of “form extensions”. A form is defined by a configuration file that must be inside the Resource/config/form_extensions/ directory of your bundle. There is no rule for the name of the file itself.

Since our job is based on the native Product CSV export job, we can copy and paste the native configuration files, then customize it.

Copy it from the PIM sources in your vendor directory, so you will always have the exact file corresponding to your version. There are actually two forms for each job: one for edit mode and one for view mode. This way we can tune very finely what is displayed for each mode.

For our form we’ll need to copy:

  • vendor/akeneo/pim-community-dev/src/Pim/Bundle/EnrichBundle/Resources/config/form_extensions/job_instance/csv_product_export_edit.yml to src/Acme/Bundle/NotifyConnectorBundle/Resources/config/form_extensions/csv_product_export_notify_edit.yml
  • vendor/akeneo/pim-community-dev/src/Pim/Bundle/EnrichBundle/Resources/config/form_extensions/job_instance/csv_product_export_show.yml to src/Acme/Bundle/NotifyConnectorBundle/Resources/config/form_extensions/csv_product_export_notify_show.yml

Now replace all occurrence of csv-product-export in these files by, let’s say, csv-product-export-notify. Indeed, each key in form configuration files must be unique across the whole application.

Note

We are aware that this is not an ideal solution and we’re working on a more satisfactory way to handle relations between forms. If you have any idea feel free to propose it or even write a contribution!

Each configuration file describes the tree of views that will be rendered in the frontend. When the frontend wants to render a form, it needs its root (the view declared without parent property, usually at the very beginning of the file). The children views are then rendered in cascade.

Now we need to declare a provider to link your job to the right form root:

1
2
3
4
5
6
7
8
services:
    acme_notifyconnector.provider.form.job_instance:
        class: '%pim_enrich.provider.form.job_instance.class%'
        arguments:
            -
                csv_product_export_notify: pim-job-instance-csv-product-export-notify
        tags:
            - { name: pim_enrich.provider.form }

Tip

Of course, if your job doesn’t require any extra fields you don’t need to use a specific form configuration. Just specify the root of the native form in your provider (that would be pim-job-instance-csv-product-export in our case).

Add a new field to the job instance form

For now we have the same form for our job than the native one. We still need to add a field to be able to configure the target URL.

To do that, we need to register a new view in our form, representing the new field:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    # src/Acme/Bundle/NotifyConnectorBundle/Resources/config/form_extensions/csv_product_export_notify_edit.yml
    pim-job-instance-csv-product-export-notify-edit-properties-url-to-notify:
        module: pim/job/common/edit/field/text
        parent: pim-job-instance-csv-product-export-notify-edit-properties
        position: 190
        targetZone: global-settings
        config:
            fieldCode: configuration.urlToNotify
            readOnly: false
            label: acme.form.job_instance.tab.properties.url_to_notify.title
            tooltip: acme.form.job_instance.tab.properties.url_to_notify.help
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    # src/Acme/Bundle/NotifyConnectorBundle/Resources/config/form_extensions/csv_product_export_notify_show.yml
    pim-job-instance-csv-product-export-notify-show-properties-url-to-notify:
        module: pim/job/common/edit/field/text
        parent: pim-job-instance-csv-product-export-notify-show-properties
        position: 190
        targetZone: global-settings
        config:
            fieldCode: configuration.urlToNotify
            readOnly: true
            label: acme.form.job_instance.tab.properties.url_to_notify.title

What does it mean? First you must specify a key. As stated above it must be unique in your application.

Then, each view has the following properties:

  • module: It’s the view module that will be rendered. The value is the key declared in the requirejs.yml file for this module.
  • parent: As forms are trees, each view must declare its parent (except the root obviously). The value is the key of the parent, that’s why keys must be unique.
  • aclResourceId: If the current user doesn’t have this ACL granted, the view (and all its children) won’t be included in the final tree.
  • targetZone: Views can have different zones. When a child wants to register itself in a parent, it can choose in which zone to be appended. Zones are defined by a data-drop-zone attribute in the DOM.
  • position: When several views are registered in the same parent for the same zone, they are ordered following their positions, in ascending order.

Job form fields need special properties defined under the config key:

  • fieldCode: The path to the data inside the form model. It’s usually configuration.myParam, with myParam being the key you use in the default values provider, constraint collection provider, and in your custom steps.
  • readOnly: Is this field in read only mode?
  • label: The translation key for the field label.
  • tooltip: The translation key for the help tooltip.

Note

Here we used the very simple text field for our needs (pim/job/common/edit/field/text module). You can also use other fields natively available in the PIM or, if you have more specific needs, create your own field.

Now we can create and edit job instances via the UI using the menu “Spread > Export profiles” then “Create export profile” button.

Add a tab to the job edit form

Let’s say that we would like to add a custom tab to our job edit form in order to manage field mappings.

First, we need to create a Form extension in our bundle:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
'use strict';
/*
 * /src/Acme/Bundle/EnrichBundle/Resources/public/js/job/product/edit/mapping.js
 */
define(['pim/form'],
    function (BaseForm) {
        return BaseForm.extend({
            configure: function () {
                this.trigger('tab:register', {
                    code: this.code,
                    isVisible: this.isVisible.bind(this),
                    label: 'Mapping'
                });

                return BaseForm.prototype.configure.apply(this, arguments);
            },
            render: function () {
                this.$el.html('Hello world');

                return this;
            },
            isVisible: function () {
                return true;
            }
        });
    }
);

For now this is a dummy extension, but this is a good start!

Let’s register this file in the requirejs configuration

1
2
3
4
5
# /src/Acme/Bundle/EnrichBundle/Resources/config/requirejs.yml

config:
    paths:
        pim/job/product/edit/mapping: acmeenrich/js/job/product/form/mapping

Now that our file is registered in requirejs configuration, we can add this extension to the product edit form:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# /src/Acme/Bundle/EnrichBundle/Resources/config/form_extensions/job_instance/csv_product_export_edit.yml

extensions:
    pim-job-instance-csv-product-export-edit-mapping:                        # The form extension code (can be whatever you want)
        module: pim/job/product/edit/mapping                                 # The requirejs module we just created
        parent: pim-job-instance-csv-product-export-edit-tabs                # The parent extension in the form where we want to be regisetred
        aclResourceId: pim_importexport_export_profile_mapping_edit          # The user will need this ACL for this extension to be registered
        targetZone: container
        position: 140                                                        # The extension position
        config:
            tabTitle: acme_enrich.form.job_instance.tab.mapping.title
            tabCode: pim-job-instance-mapping


# /src/Acme/Bundle/EnrichBundle/Resources/config/form_extensions/job_instance/csv_product_export_show.yml

extensions:
    pim-job-instance-csv-product-export-show-mapping:                        # The form extension code (can be whatever you want)
        module: pim/job/product/show/mapping                                 # The requirejs module we just created
        parent: pim-job-instance-csv-product-export-show-tabs                # The parent extension in the form where we want to be regisetred
        aclResourceId: pim_importexport_export_profile_mapping_show          # The user will need this ACL for this extension to be registered
        targetZone: container
        position: 140                                                        # The extension position
        config:
            tabTitle: acme_enrich.form.job_instance.tab.mapping.title
            tabCode: pim-job-instance-mapping

After a cache clear (app/console cache:clear), you should see your new tab in the job edit form. If not, make sure that you ran the app/console assets:install –symlink web command.

Now that we have our extension loaded in our form, we can add some logic into it, check how to customize the UI.