Demo Microservice

ecos-demo-app is a reference microservice of the Citeck platform, designed for developers who build their own microservices or integrate external systems with the platform. The application is written in Java using Spring Boot and covers the most common scenarios for working with the ECOS API.

This document is intended for developers (Java/Kotlin) who are getting started with the Citeck platform or want to understand common integration patterns.

The demo application implements the following platform mechanisms:

  • Records API — a custom RecordsDAO with in-memory data storage and a full set of CRUD operations;

  • Actions — server-side actions, including sending email notifications and creating child entities via action;

  • Commands — asynchronous messaging between microservices;

  • Jobs — scheduling regular task execution using the @Scheduled annotation;

  • Events — subscribing to platform events (record creation and modification) with support for transactional and asynchronous listeners;

  • External Tasks — executing BPMN process tasks in an external service;

  • BPMN process — a complete business process cycle with auto-start, a user task, and an external task.


The repository ecos-demo-app contains an application demonstrating Citeck’s capabilities.

You can get acquainted with:

See more about Citeck artifacts, applications.

When starting the application, a section «Demo type/Demo type» will appear in the left menu by default with two journals:

  • Demo type journal/Demo type journal — the main demo journal with a BPMN process and various demonstration actions. The scenario written below is based on this type.

  • in-memory demo type/Demo in-memory type — a journal with in-memory entities to demonstrate working with a custom RecordsDAO defined in ru.citeck.ecos.webapp.demo.records.DemoInMemRecordsDao. You can create/view/edit/delete records in this journal and see what has changed in DemoInMemRecordsDao.

Getting Started

If you are not familiar with the Citeck platform and want to run the software locally, we recommend you download the Dockerized version of Community demo. Details on installation.

The microservice is written using Spring Boot.

Note

The following applications from the Citeck deployment are required to run this application:

  • zookeeper;

  • rabbitmq;

  • ecos-model;

  • ecos-registry.

Running

Clone the repository to your development environment. To run the application, execute:

./mvnw spring-boot:run

If your IDE supports running Spring Boot applications directly, you can easily run the class ru.citeck.ecos.webapp.demo.EcosDemoApp without additional configuration.

Usage Scenario

  1. Start ecos-demo-app.

  2. In Citeck, click «Create/Create» in the top left corner.

  3. Select «Demo type/Demo type» -> «Demo type/Demo type».

  4. Enter the value «error» in the «Name/Name» field and click the «Save/Save» button. You should see an error from the transactional listener defined in ru.citeck.ecos.webapp.demo.events.DemoEcosEventListener.

  5. Change the value of the «Name/Name» field to any other and fill in the remaining fields.

  6. After creation, you will see information about the created record:

  • The status will be «New/New». This is defined in the defaultStatus property in the type configuration — src/main/resources/eapps/artifacts/model/type/demo-type.yml

  • Task widgets will display an active task for the current user. The BPMN process is started because we have a process definition in src/main/resources/eapps/artifacts/process/bpmn/demo-process.bpmn.xml with the flags ecos:enabled=”true” and ecos:autoStartEnabled=”true”.

  1. Click the «Done/Done» button in the current task widget.

  2. The task will disappear and an external task will be started — ru.citeck.ecos.webapp.demo.exttask.DemoExternalTask.

  3. After about 5–10 seconds, you can refresh the browser tab and see the new status «Completed/Completed» and the filled field «Field generated in external task/Field generated in external task». At this point, the BPMN process completes.

  4. You can click «Send demo email/Send demo email» to test the special action for sending an email.

  • Action class: ru.citeck.ecos.webapp.demo.actions.SendDemoEmailAction

  • Action definition: src/main/resources/eapps/artifacts/ui/action/send-demo-email-action.yml

  • Email template: src/main/resources/eapps/artifacts/notification/template/demo-email.html.ftl.

  • The result email can be found in mailhog (if you haven’t changed the default email settings) — http://localhost:8025/

  1. After testing email sending, you can click «Create child entity/Create child entity» to test the ability to create related objects via an action.

  • Action definition: src/main/resources/eapps/artifacts/ui/action/create-child-entity-action.yml

Description of Microservice Entities

Artifacts

The project artifacts are located in the folder .../src/main/resources/eapps/artifacts. The first two directory levels correspond to the artifact type. For example:

  • app/artifact-patch

  • model/type

  • notification/template

  • process/bpmn

  • ui/action, /form, /journal

More about Citeck artifacts

Classes

Record – an entity with a set of attributes and a record identifier (RecordRef).

Below is a simple example of a RecordsDAO with in-memory entity storage. This RecordsDAO demonstrates simple basic CRUD operations in the Records API and does not implement functions such as associations, content storage, permission checks, etc. See detailed description of CRUD operations

Link to Records in Git repository

Warning

Attention: All data will be lost after restarting the application. Do not use for production environment.

@Component
public class DemoInMemRecordsDao extends AbstractRecordsDao
        implements RecordsQueryDao, RecordAttsDao, RecordMutateDao, RecordDeleteDao {

    public static final String ID = "demo-inmem-data";

    /**
    * Создание простого хранилища для записей. Все данные будут потеряны после рестарта приложения.
    */
    private final Map<String, SimpleDto> records = new ConcurrentHashMap<>();

    /**
    * Запрос Query records поддерживает только язык «предикатов».
    * @param recordsQuery – параметры запроса, отправляемые с фронтенда
    * @return найденные записи и информацию об общем количестве без пагинации
    */
    @Nullable
    @Override
    public RecsQueryRes<?> queryRecords(@NotNull RecordsQuery recordsQuery) {

        // О предикатах подробно можно прочитать по ссылке
        // https://citeck-ecos.readthedocs.io/ru/latest/general/%D0%AF%D0%B7%D1%8B%D0%BA_%D0%BF%D1%80%D0%B5%D0%B4%D0%B8%D0%BA%D0%B0%D1%82%D0%BE%D0%B2.html
        if (!PredicateService.LANGUAGE_PREDICATE.equals(recordsQuery.getLanguage())) {
            return null;
        }

        Predicate predicate = recordsQuery.getQuery(Predicate.class);

        QueryPage page = recordsQuery.getPage();
        List<SimpleDto> fullResult = predicateService.filterAndSort(
                records.values(),
                predicate,
                recordsQuery.getSortBy(),
                page.getSkipCount(),
                page.getMaxItems()
        );

        RecsQueryRes<SimpleDto> recsQueryRes = new RecsQueryRes<>();
        recsQueryRes.setTotalCount(records.size());
        recsQueryRes.setRecords(fullResult);

        return recsQueryRes;
    }

    /**
    * Получить данные рекорда по localId.
    * @return сам рекорд или null
    */
    @Nullable
    @Override
    public Object getRecordAtts(@NotNull String localId) {
        return records.get(localId);
    }

    /**
    * Создание или обновление рекорда.
    * Если recordAtts.getId() пустая строка, то создается новый рекорд со сгенерированным localId
    * @param recordAtts localId (String) и key-value map (ObjectData) с атрибутами сущности
    * @return localId существующего или созданного рекорда.
    */
    @NotNull
    @Override
    public String mutate(@NotNull LocalRecordAtts recordAtts) {
        SimpleDto recToMutate;
        if (recordAtts.getId().isEmpty()) { //создание
            // Обычно в других не демо-версиях RecordsDao, когда getId() пустой,
            // id можно указать в атрибутах, но здесь мы не реализуем эту логику.
            // Вы можете просмотреть исходный код ru.citeck.ecos.records3.record.dao.impl.mem.InMemDataRecordsDao
            // чтобы проверить правильную реализацию метода mutate.
            recToMutate = new SimpleDto(UUID.randomUUID().toString());
        } else {
            recToMutate = records.get(recordAtts.getId()); // обновление
            if (recToMutate == null) {
                throw new IllegalArgumentException("Record with id " + recordAtts.getId() + " is not found");
            }
            recToMutate = new SimpleDto(recToMutate);
            recToMutate.modified = Instant.now();
        }
        Json.getMapper().applyData(recToMutate, recordAtts.getAtts());
        if (recToMutate.id.isBlank()) {
            throw new IllegalArgumentException("Record id is empty after mutation. Atts: " + recordAtts);
        }
        records.put(recToMutate.id, recToMutate);
        return recToMutate.id;
    }

    /**
    * Удаление определенных рекордов.
    * @param localId удаленной записи
    * @return localId существующего или созданного рекорда.
    */
    @NotNull
    @Override
    public DelStatus delete(@NotNull String localId) {
        records.remove(localId);
        return DelStatus.OK;
    }

    @NotNull
    @Override
    public String getId() {
        return ID;
    }

    /**
    * Создание DTO
    */
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class SimpleDto {

        private String id;
        private String textField;
        private int numField;

        private Instant created;
        private Instant modified;

        public SimpleDto(String id) {
            this.id = id;
            created = Instant.now();
            modified = created;
        }

        public SimpleDto(SimpleDto other) {
            this.id = other.id;
            this.textField = other.textField;
            this.numField = other.numField;
            this.created = other.created;
            this.modified = other.modified;
        }

        /**
        * Данный метод вызывается, когда скаляр '?disp' загружается из рекорда.
        * Подробно о скалярах https://citeck-ecos.readthedocs.io/ru/latest/general/ECOS_Records.html#id11
        * 'getDisplayName' специальное имя в DTO для скаляра '?disp'
        */
        public MLText getDisplayName() {
            Map<Locale, String> nameData = new HashMap<>();
            nameData.put(I18nContext.ENGLISH, "Demo in-mem '" + textField + "'");
            nameData.put(I18nContext.RUSSIAN, "Демо in-mem '" + textField + "'");
            return new MLText(nameData);
        }

        /**
        * Данный метод вызывается, когда атрибут '_type' attribute загружается из рекорда.
        * 'getEcosType' специальное имя в DTO  для атрибута DTO for '_type' .
        * Движок добавляет дополнительную логику для данного метода:
        * Если результат метода EntityRef или строка, начинающаяся с 'emodel/type@', то результат будет EntityRef.valueOf(methodResult).
        * Если результат метода строка и она не начинается с 'emodel/type@', то движок добавляет префикс 'emodel/type@' и возвращает результат EntityRef.valueOf.
        */
        public String getEcosType() {
            // type config: src/main/resources/eapps/artifacts/model/type/demo-inmem-type.yaml
            return "demo-inmem-type";
        }

        /**
        * Простой геттер для атрибута _created.
        * Аннотация AttName требуется, чтобы изменить имя атрибута по умолчанию "created" на "_created".
        * '_created' специальный мета атрибут для любого рекорда, который должен информировать, когда рекорд был создан.
        */
        @AttName(RecordConstants.ATT_CREATED)
        public Instant getCreated() {
            return created;
        }

        /**
        * Простой геттер для атрибута _modified.
        * Аннотация AttName требуется, чтобы изменить имя атрибута по умолчанию "modified" на "_modified".
        * '_modified' специальный мета атрибут для любого рекорда, который должен информировать, когда рекорд был изменен последний раз.
        */
        @AttName(RecordConstants.ATT_MODIFIED)
        public Instant getModified() {
            return modified;
        }
    }
}

Actions - Citeck artifacts in json or yaml format with type ui/action. Link to Action in Git repository

Action artifacts are located in the folder …/src/main/resources/eapps/artifacts/ui/action

@Component
@RequiredArgsConstructor
public class SendDemoEmailAction extends AbstractRecordsDao implements ValueMutateDao<SendDemoEmailAction.ActionData> {

    /**
    * Идентификатор RecordsDAO. Используется для определения того, какой DAO должен обрабатывать запрос на мутацию. Это вторая половина EntityRef после'/' и до '@'
    * Это 'send-demo-email'  в составе ecos-demo-app/send-demo-email@ 629fbd31-788a-4232-9de9-d737e5b07795
    * В запросах API этот идентификатор сочетается с appName называемым sourceId. Например: 'ecos-demo-app/send-demo-email'
    */
    public static final String ID = "send-demo-email";

    /**
    * Шаблон email
    * Загружается из src/main/resources/eapps/artifacts/notification/template/demo-email.html.ftl
    */
    private static final EntityRef TEMPLATE_REF = EntityRef.create(
            AppName.NOTIFICATIONS,
            "template",
            "demo-email"
    );

    private final NotificationService notificationService;

    @Nullable
    @Override
    public Object mutate(@NotNull ActionData actionData) {

        String currentUser = AuthContext.getCurrentUser();
        EntityRef currentUserRef = AuthorityType.PERSON.getRef(currentUser);
        String email = recordsService.getAtt(currentUserRef, "email").asText();
        if (StringUtils.isBlank(email)) {
            throw new RuntimeException("Current user doesn't have email. Please open user profile and change it");
        }

        // Дополнительные метаданные могут использоваться для добавления пользовательских данных при отправке уведомления.
        // Шаблон уведомления может загружать любое значение из этих данных, используя '$' как префикс перед ключом
        // Например:
        // Template model = {"anyAliasWhichCanBeUsedInFtlTemplate": "$additionalStr"}
        // Ftl template   = "Some text ${anyAliasWhichCanBeUsedInFtlTemplate}"
        // Result         = "Some text additional-string-value"
        Map<String, Object> additionalMeta = new HashMap<>();
        additionalMeta.put("additionalStr", "additional-string-value");
        // Переменные могут содержать простые скаляры (string/number/boolean/etc.) или ссылки на любые объекты в системе
        // Например, мы добавляем сюда ссылку на текущего пользователя.
        additionalMeta.put("additionalUserRef", EntityRef.create(AppName.EMODEL, "person", currentUser));
        // Также можно использовать значения DTO, и шаблон может извлекать из них данные.
        additionalMeta.put("actionData", actionData);

        // NotificationService используется для ручной отправки уведомлений
        // Шаблон уведомления определяет модель с атрибутами, которые следует загрузить из рекорда и additionalMeta
        // Сервис работает следующим образом:
        // 1. Загрузить список атрибутов, необходимый для предоставленного templateRef
        // 2. Загрузить необходимые атрибуты из предоставленной записи и additionalMeta
        // 3. Отправить команду с загруженными данными в приложение уведомлений через RabbitMQ
        // Метод 'send' не ждет пока сообщение действительно будет отправлено
        notificationService.send(new Notification.Builder()
                .addRecipient(email)
                .record(actionData.entityRef)
                .notificationType(NotificationType.EMAIL_NOTIFICATION)
                .additionalMeta(additionalMeta)
                .templateRef(TEMPLATE_REF)
                .build());

        // с настройками по умолчанию отправленный email можно увидеть в mailhog - http://localhost:8025/
        return null;
    }

    @NotNull
    @Override
    public String getId() {
        return ID;
    }

    @Data
    public static class ActionData {
        private EntityRef entityRef;
        private String comment;
    }
}

On the frontend, the action is called as follows:

let rec = Records.getRecordToEdit('ecos-demo-app/send-demo-email@');
rec.att('entityRef', 'emodel/demo-type@629fbd31-788a-4232-9de9-d737e5b07795'); // any EntityRef
rec.att('comment', 'any comment');
await rec.save();

where

  • ecos-demo-app - appName

  • send-demo-email - RecordsDAO identifier. Used to determine which DAO should handle the mutation request (see SendDemoEmailAction.ID).

  • EntityRef - unique identifier of an entity in the Citeck system.

Commands in Citeck are mainly used for asynchronous messaging between applications.

Command Executor

In the service where the command is sent, an Executor must be implemented that will process the DTO. Link to Command Executor in Git repository

This demo executor covers the basic concepts. The command type will be calculated based on the CommandType annotation for the generic CommandExecutor type.

@Slf4j
@Component
public class DemoCommandExecutor implements CommandExecutor<DemoCommandExecutor.DemoCommandDto> {

    public static final String TYPE = "demo-command";

    @Nullable
    @Override
    public Object execute(DemoCommandDto demoCommandDto) {
        log.info("Command received: " + demoCommandDto);
        return null;
    }

    @Data
    @CommandType(TYPE)
    public static class DemoCommandDto {
        private EntityRef entityRef;
        private String comment;
    }
}

The command itself

In the service from which we send the command request, we use CommandService to send the command. Link to Command in Git repository

@Component
@RequiredArgsConstructor
public class SendDemoCommandAction extends AbstractRecordsDao implements ValueMutateDao<SendDemoCommandAction.ActionData> {

    public static final String ID = "send-demo-command";

    private final CommandsService commandsService;

    @Nullable
    @Override
    public Object mutate(@NotNull ActionData actionData) {

        Map<String, Object> body = new HashMap<>();
        body.put("entityRef", actionData.entityRef);
        body.put("comment", actionData.comment);

        // Command execution result you can see in logs
        commandsService.execute(b -> {
            b.withTargetApp("ecos-demo-app"); // эта команда отправляется в приложение
            b.withBody(body); // body может быть любой объект Map или DTO
            b.withType("demo-command"); // command executor будет выбран по этому типу
            return Unit.INSTANCE;
        });

        return null;
    }

    /**
    * переопределение DAO
    */

    @NotNull
    @Override
    public String getId() {
        return ID;
    }

    /**
    * Фронтенд отправляет 2 атрибута и создается инстанс ActionData
    */


    @Data
    public static class ActionData {
        private EntityRef entityRef;
        private String comment;
    }
}

Job allows scheduling one-time or regular execution of tasks. Link to Job in Git repository

@Slf4j
@Component
@RequiredArgsConstructor
public class SimpleAnnotatedJob {

    private final AtomicInteger counter = new AtomicInteger();

    private final RecordsService recordsService;

    /**
    * Задание будет выполнено как системное.
    * @see Scheduled
    */
    @Scheduled(fixedDelayString = "${ecos.demo.simple-annotated-job.delay}") // задержка настраивается в application.yml
    void doSomeWork() {
        RecsQueryRes<EntityRef> queryRes = recordsService.query(
                RecordsQuery.create()
                        .withEcosType("demo-type") // Запрос всех записей с типом demo-type, у которых есть childEntities
                        // sourceId будет загружен из ecosType по умолчанию,
                        // но вы можете указать это явно
                        //.withSourceId(AppName.EMODEL + "/demo-type")
                        .withQuery(Predicates.notEmpty("childEntities"))
                        .withMaxItems(0) // Query for totalCount without records
                        .build()
        );
        log.info("Simple annotated job example #" + counter.incrementAndGet() +
                ". Demo records with children: " + queryRes.getTotalCount()); // вывод количества в лог
    }
}

Events in Citeck allow changing the attribute composition needed by an event subscriber without modifying the event source.

Let’s consider creating an EventListener class. Link to Event in Git repository

@Slf4j
@Component
@RequiredArgsConstructor
public class DemoEcosEventListener {

    private final EventsService eventsService;

    @PostConstruct
    public void init() {

        Predicate filter = Predicates.and( // Задается фильтр для поиска
            // Для транзакционных слушателей очень важна фильтрация по типу.
            // чтобы избежать генерации ненужных событий.
            Predicates.eq("typeDef.id", "demo-type"),
            Predicates.contains("record.textField", "error")
        );

        eventsService.<UserCreatedOrUpdatedEventAtts>addListener(builder -> {

            // Типы событий
            //
            // Часто используемые типы событий:
            // ru.citeck.ecos.events2.type.RecordChangedEvent.TYPE ("record-changed")
            // ru.citeck.ecos.events2.type.RecordDeletedEvent.TYPE ("record-deleted")
            // ru.citeck.ecos.events2.type.RecordStatusChangedEvent.TYPE ("record-status-changed")
            // ru.citeck.ecos.events2.type.RecordDraftStatusChangedEvent.TYPE ("record-draft-status-changed")
            // ru.citeck.ecos.events2.type.RecordCreatedEvent.TYPE ("record-created")
            // ru.citeck.ecos.events2.type.RecordContentChangedEvent.TYPE ("record-content-changed")
            //
            // Атрибуты для этих типов событий можно найти в классах выше.
            builder.withEventType(RecordCreatedEvent.TYPE); // подписка на создание записи

            // Класс данных определяет DTO с атрибутами, которые должны быть загружены из события и отправлены слушателю
            builder.withDataClass(UserCreatedOrUpdatedEventAtts.class); // какие данные выбирать из события

            // Транзакционный флаг дает слушателю следующие возможности:
            // 1. Слушатель вызывается сразу после возникновения события
            // 2. Если слушателю отправить ошибку, то транзакция будет отменена
            // но у этого флага есть следующие недостатки:
            // 1. Если приложение не запускается и произошло событие, транзакция всегда будет завершаться с ошибкой.
            // 2. Если слушатель проделывает какую-то сложную работу, то отзывчивость системы будет хуже.
            //
            // Если выбрать transactional=false, то слушатель будет вызываться асинхронно
            // после фиксации транзакции
            builder.withTransactional(true);

            // 'J' в конце имени метода означает 'Java'.
            // Методы API без постфикса изначально предназначены для использования в Kotlin.
            // withAction определяет метод, который должен вызываться при возникновении события.
            builder.withActionJ(this::processCreatedOrUpdatedEvent);

            // Фильтр проверяет любые данные в событии мгновенно, когда событие произошло.
            // Если фильтр не соответствует, событие не будет создано.
            builder.withFilter(filter);
            return Unit.INSTANCE;
        });

        // Добавить слушателя к измененному событию
        eventsService.<UserCreatedOrUpdatedEventAtts>addListener(builder -> {
            builder.withEventType(RecordChangedEvent.TYPE);
            builder.withDataClass(UserCreatedOrUpdatedEventAtts.class);
            builder.withTransactional(true);
            builder.withActionJ(this::processCreatedOrUpdatedEvent);
            builder.withFilter(filter);
            return Unit.INSTANCE;
        });
    }

    private void processCreatedOrUpdatedEvent(UserCreatedOrUpdatedEventAtts event) { // вызов метода для  получения инстанса класса, наполненного данными, которые можно фильтровать
        log.warn("Process created or updated event for record " + event.entityRef + ". TextField: " + event.textField); // вывод полученных данных в лог
        throw new RuntimeException("You can't write 'error' in text field. Current value: '" + event.textField + "'");  // вывод ошибки, препятствующей выполнению действий
    }

    @Data
    public static class UserCreatedOrUpdatedEventAtts { // создание на сервере инстанса этого класса и наполнение его данными.
        @AttName("record?id") // что создалось
        private EntityRef entityRef;
        @AttName("record.textField") // полученные данные поля textField
        private String textField;
    }
}

External tasks allow executing tasks using external systems.

Example of an external task for the demo BPMN process - link in Git repository

The business process artifact is located in the folder …/src/main/resources/eapps/artifacts/process/bpmn

@Slf4j
@Component
@RequiredArgsConstructor
@ExternalTaskSubscription("demo-ext-task")
public class DemoExternalTask implements ExternalTaskHandler {

    private final RecordsService recordsService;

    @Override
    // Если вы обернете метод выполнения в RunInTransaction, то внешняя задача
    //  в процессе должен быть флаг asyncAfter, чтобы избежать ошибок транзакций
    @RunInTransaction
    @ExternalTaskRetry(retries = 10, retryTimeout = 10_000) // // настройка повторной обработки задачи, если в процессе обработки возникла техническая ошибка
    public void execute(ExternalTask externalTask, ExternalTaskService externalTaskService) {

        String documentRef = externalTask.getVariable("documentRef"); // получить ссылку на документ

        log.info("External task for document: " + documentRef); // вывести в лог полученную ссылку на документ

        String textField = recordsService.getAtt(documentRef, "textField").asText(); // получить данные поля textField

        log.info("Text field: '" + textField + "'"); // вывести в лог полученные данные поля

        /*
        Вы можете использовать простую мутацию одного атрибута, используя метод mutateAtt
        или используйте расширенный метод с RecordAtts. Например, обновить данные в поле extTaskField:

        RecordAtts record = new RecordAtts(documentRef);
        record.setAtt("extTaskField", "TextField: " + textField);
        record.setAtt("otherAttribute", "otherValue");
        recordsService.mutate(record);
        */
        recordsService.mutateAtt(documentRef, "extTaskField", "TextField: " + textField);

        // Здесь можно указать бизнес-ошибку. Эта ошибка должна быть правильно обработана в процессе
        // externalTaskService.handleBpmnError(externalTask, "error-code", "error-message");

        externalTaskService.complete(externalTask);
    }
}

Building

To build a docker image with the microservice, execute the command:

./mvnw -Pprod clean package jib:dockerBuild -Djib.docker.image.tag=custom

After building, you can run the ecos-demo-app:custom container using docker.

Testing

To run your application’s tests, execute:

./mvnw clean test