All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.ehrbase.rest.openehr.OpenehrCompositionController Maven / Gradle / Ivy

There is a newer version: 2.11.0
Show newest version
/*
 * Copyright (c) 2024 vitasystems GmbH.
 *
 * This file is part of project EHRbase
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.ehrbase.rest.openehr;

import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.ehrbase.api.rest.HttpRestContext.EHR_ID;
import static org.ehrbase.api.rest.HttpRestContext.TEMPLATE_ID;
import static org.springframework.web.util.UriComponentsBuilder.fromPath;

import com.nedap.archie.rm.composition.Composition;
import com.nedap.archie.rm.support.identification.ObjectId;
import com.nedap.archie.rm.support.identification.ObjectVersionId;
import java.net.URI;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.ehrbase.api.exception.InternalServerException;
import org.ehrbase.api.exception.ObjectNotFoundException;
import org.ehrbase.api.exception.PreconditionFailedException;
import org.ehrbase.api.rest.EHRbaseHeader;
import org.ehrbase.api.rest.HttpRestContext;
import org.ehrbase.api.service.CompositionService;
import org.ehrbase.api.service.SystemService;
import org.ehrbase.openehr.sdk.response.dto.CompositionResponseData;
import org.ehrbase.openehr.sdk.response.dto.ehrscape.CompositionDto;
import org.ehrbase.openehr.sdk.response.dto.ehrscape.StructuredString;
import org.ehrbase.rest.BaseController;
import org.ehrbase.rest.openehr.format.CompositionRepresentation;
import org.ehrbase.rest.openehr.format.OpenEHRMediaType;
import org.ehrbase.rest.openehr.specification.CompositionApiSpecification;
import org.ehrbase.rest.util.InternalResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

/**
 * Controller for /composition resource as part of the EHR sub-API of the openEHR REST API
 */
@ConditionalOnMissingBean(name = "primaryopenehrcompositioncontroller")
@RestController
@RequestMapping(
        path = BaseController.API_CONTEXT_PATH_WITH_VERSION + "/ehr",
        produces = {
            MediaType.APPLICATION_JSON_VALUE,
            MediaType.APPLICATION_XML_VALUE,
            OpenEHRMediaType.APPLICATION_WT_FLAT_SCHEMA_JSON_VALUE,
            OpenEHRMediaType.APPLICATION_WT_STRUCTURED_SCHEMA_JSON_VALUE
        })
public class OpenehrCompositionController extends BaseController implements CompositionApiSpecification {

    private final CompositionService compositionService;

    private final SystemService systemService;

    @Autowired
    public OpenehrCompositionController(CompositionService compositionService, SystemService systemService) {
        this.compositionService = Objects.requireNonNull(compositionService);
        this.systemService = systemService;
    }

    @PostMapping(
            value = "/{ehr_id}/composition",
            consumes = {
                MediaType.APPLICATION_JSON_VALUE,
                MediaType.APPLICATION_XML_VALUE,
                OpenEHRMediaType.APPLICATION_WT_FLAT_SCHEMA_JSON_VALUE,
                OpenEHRMediaType.APPLICATION_WT_STRUCTURED_SCHEMA_JSON_VALUE
            })
    @ResponseStatus(value = HttpStatus.CREATED)
    @Override
    public ResponseEntity createComposition(
            @RequestHeader(value = "openEHR-VERSION", required = false) String openehrVersion,
            @RequestHeader(value = "openEHR-AUDIT_DETAILS", required = false) String openehrAuditDetails,
            @RequestHeader(value = CONTENT_TYPE) String contentType,
            @RequestHeader(value = ACCEPT, required = false) String accept,
            @RequestHeader(value = PREFER, required = false) String prefer,
            @PathVariable(value = "ehr_id") String ehrIdString,
            @RequestParam(value = "templateId", required = false) String templateId,
            @RequestParam(value = "format", required = false) String format,
            @RequestBody String composition) {

        var ehrId = getEhrUuid(ehrIdString);

        var requestRepresentation = extractCompositionRepresentation(contentType, format);
        var responseRepresentation = extractCompositionRepresentation(accept, format);

        var compoObj = compositionService.buildComposition(composition, requestRepresentation.format, templateId);
        var compositionUuid = compositionService
                .create(ehrId, compoObj)
                .orElseThrow(() -> new InternalServerException("Failed to create composition"));
        URI uri = createLocationUri(EHR, ehrId.toString(), COMPOSITION, compositionUuid.toString());

        // whatever is required by REST spec - CONTENT_TYPE only needed for 201, so handled separately
        List headerList = Arrays.asList(LOCATION, ETAG, LAST_MODIFIED);

        Optional> respData = buildCompositionResponseData(
                ehrId,
                compositionUuid,
                1,
                responseRepresentation,
                uri,
                headerList,
                RETURN_REPRESENTATION.equals(prefer) ? () -> new CompositionResponseData(null, null) : () -> null);

        // returns 201 with body + headers, 204 only with headers or 500 error depending on what processing above yields
        return respData.map(i -> Optional.ofNullable(i.getResponseData())
                        .map(StructuredString::getValue)
                        .map(j -> ResponseEntity.created(uri)
                                .headers(i.getHeaders())
                                .body(j))
                        // when the body is empty
                        .orElse(ResponseEntity.noContent()
                                .headers(i.getHeaders())
                                .build()))
                // when no response could be created at all
                .orElse(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
    }

    @PutMapping(
            value = "/{ehr_id}/composition/{versioned_object_uid}",
            consumes = {
                MediaType.APPLICATION_JSON_VALUE,
                MediaType.APPLICATION_XML_VALUE,
                OpenEHRMediaType.APPLICATION_WT_FLAT_SCHEMA_JSON_VALUE,
                OpenEHRMediaType.APPLICATION_WT_STRUCTURED_SCHEMA_JSON_VALUE
            })
    @Override
    public ResponseEntity updateComposition(
            String openehrVersion,
            @RequestHeader(value = "openEHR-AUDIT_DETAILS", required = false) String openehrAuditDetails,
            @RequestHeader(value = CONTENT_TYPE, required = false) String contentType,
            @RequestHeader(value = ACCEPT, required = false) String accept,
            @RequestHeader(value = PREFER, required = false) String prefer,
            @RequestHeader(value = IF_MATCH) String ifMatch,
            @PathVariable(value = "ehr_id") String ehrIdString,
            @PathVariable(value = "versioned_object_uid") String versionedObjectUidString,
            @RequestParam(value = "templateId", required = false) String templateId,
            @RequestParam(value = "format", required = false) String format,
            @RequestBody String composition) {

        UUID ehrId = getEhrUuid(ehrIdString);
        UUID versionedObjectUid = getCompositionVersionedObjectUidString(versionedObjectUidString);

        CompositionRepresentation requestRepresentation = extractCompositionRepresentation(contentType, format);
        CompositionRepresentation responseRepresentation = extractCompositionRepresentation(accept, format);

        Composition compoObj =
                compositionService.buildComposition(composition, requestRepresentation.format, templateId);

        // If body already contains a composition uid it must match the {versioned_object_uid} in request url
        Optional inputUuid = getUidFrom(compoObj);
        inputUuid.ifPresent(id -> {
            // TODO currently the this part of the spec is implemented as "the request body's composition version_uid
            // must be compatible to the given versioned_object_uid"
            // TODO it is further unclear what exactly the REST spec's "match" means, see:
            // https://github.com/openEHR/specifications-ITS-REST/issues/83
            if (!versionedObjectUid.equals(extractVersionedObjectUidFromVersionUid(id)))
                throw new PreconditionFailedException(
                        "UUID from input must match given versioned_object_uid in request URL");
        });

        final Optional> respData;
        try {
            ObjectVersionId ifMatchId = new ObjectVersionId(ifMatch);
            // ifMatch header has to be tested for correctness already above
            String compositionVersionUid = compositionService
                    .update(ehrId, ifMatchId, compoObj)
                    .orElseThrow(() -> new InternalServerException("Failed to create composition"))
                    .toString();

            URI uri = createLocationUri(EHR, ehrId.toString(), COMPOSITION, compositionVersionUid);

            // whatever is required by REST spec - CONTENT_TYPE only needed for 200, so handled separately
            List headerList = Arrays.asList(LOCATION, ETAG, LAST_MODIFIED);

            UUID compositionId = extractVersionedObjectUidFromVersionUid(compositionVersionUid);
            int nextVersion = Integer.parseInt(ifMatchId.getVersionTreeId().getValue()) + 1;
            respData = buildCompositionResponseData(
                    ehrId,
                    compositionId,
                    nextVersion,
                    responseRepresentation,
                    uri,
                    headerList,
                    RETURN_REPRESENTATION.equals(prefer) ? () -> new CompositionResponseData(null, null) : () -> null);

        } catch (ObjectNotFoundException e) { // composition not found
            return ResponseEntity.notFound().build();
        } // composition input not parsable / buildable -> bad request handled by BaseController class

        // returns 200 with body + headers, 204 only with headers or 500 error depending on what processing above yields
        return respData.map(i -> Optional.ofNullable(i.getResponseData())
                        .map(StructuredString::getValue)
                        .map(j -> ResponseEntity.ok().headers(i.getHeaders()).body(j))
                        .orElse(ResponseEntity.noContent()
                                .headers(i.getHeaders())
                                .build()))
                .orElse(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
    }

    @DeleteMapping("/{ehr_id}/composition/{preceding_version_uid}")
    @ResponseStatus(value = HttpStatus.NO_CONTENT)
    @Override
    public ResponseEntity deleteComposition(
            @RequestHeader(value = "openEHR-VERSION", required = false) String openehrVersion,
            @RequestHeader(value = "openEHR-AUDIT_DETAILS", required = false) String openehrAuditDetails,
            @PathVariable(value = "ehr_id") String ehrIdString,
            @PathVariable(value = "preceding_version_uid") String precedingVersionUid) {
        UUID ehrId = getEhrUuid(ehrIdString);

        HttpHeaders headers = new HttpHeaders();

        // prepare header data

        try { // the actual deleting
            // precedingVersionUid needs to be checked already
            ObjectVersionId targetObjId = new ObjectVersionId(precedingVersionUid);
            compositionService.delete(ehrId, targetObjId);

            // set next deleted version
            int nextVersion = Integer.parseInt(targetObjId.getVersionTreeId().getValue()) + 1;
            targetObjId.getVersionTreeId().setValue(String.valueOf(nextVersion));
            URI uri = createLocationUri(EHR, ehrId.toString(), COMPOSITION, targetObjId.getValue());

            headers.setLocation(uri);
            headers.setETag("\"" + targetObjId.getValue() + "\"");
            headers.setLastModified(ZonedDateTime.of(LocalDateTime.now(), ZoneId.systemDefault())
                    .toInstant()
                    .toEpochMilli());

            UUID compositionUid = UUID.fromString(targetObjId.getObjectId().getValue());
            Integer version = Integer.parseInt(targetObjId.getVersionTreeId().getValue());

            HttpRestContext.register(
                    EHR_ID,
                    ehrId,
                    HttpRestContext.LOCATION,
                    getLocationUrl(compositionUid, ehrId, version),
                    TEMPLATE_ID,
                    compositionService.retrieveTemplateId(compositionUid));

            return ResponseEntity.noContent().headers(headers).build();
        } catch (ObjectNotFoundException e) {
            // if composition not available at all --> 404
            throw new ObjectNotFoundException(
                    COMPOSITION,
                    "No EHR with the supplied ehr_id or no COMPOSITION with the supplied " + "preceding_version_uid.");
        }
    }

    private String getLocationUrl(UUID versionedObjectUid, UUID ehrId, int version) {
        if (version == 0) version = compositionService.getLastVersionNumber(versionedObjectUid);

        return fromPath("/{ehrSegment}/{ehrId}/{compositionSegment}/{compositionId}::{nodeName}::{version}")
                .build(EHR, ehrId.toString(), COMPOSITION, versionedObjectUid, systemService.getSystemId(), version)
                .toString();
    }

    /**
     * This mapping combines both GETs "/{ehr_id}/composition/{version_uid}" (via overlapping path)
     * and "/{ehr_id}/composition/{versioned_object_uid}{?version_at_time}" (here). This is necessary
     * because of the overlapping paths. Both mappings are specified to behave almost the same, so
     * this solution works in this case.
     */
    @GetMapping("/{ehr_id}/composition/{versioned_object_uid}")
    @Override
    public ResponseEntity getComposition(
            @RequestHeader(value = ACCEPT, required = false) String accept,
            @PathVariable(value = "ehr_id") String ehrIdString,
            @PathVariable(value = "versioned_object_uid") String versionedObjectUid,
            @RequestParam(value = "format", required = false) String format,
            @RequestParam(value = "version_at_time", required = false) String versionAtTime) {

        UUID ehrId = getEhrUuid(ehrIdString);

        CompositionRepresentation responseRepresentation = extractCompositionRepresentation(accept, format);

        // Note: Since this method can be called by another mapping as "almost overloaded" function some parameters
        // might be semantically named wrong in that case. E.g. versionedObjectUid can contain a versionUid.
        // Note: versionUid should be of format "uuid::domain::version", versionObjectUid of format "uuid"
        UUID compositionUid = extractVersionedObjectUidFromVersionUid(
                versionedObjectUid); // extracts UUID from long or short notation

        int version = extractVersionFromVersionUid(versionedObjectUid)
                .or(() -> decodeVersionAtTime(versionAtTime)
                        .map(t -> Optional.ofNullable(compositionService.getVersionByTimestamp(compositionUid, t))
                                .orElseThrow(() -> new ObjectNotFoundException(
                                        COMPOSITION, "No composition version matching the timestamp condition"))))
                .orElseGet(() -> compositionService.getLastVersionNumber(compositionUid));

        if (compositionService.isDeleted(ehrId, compositionUid, version)) {
            return new ResponseEntity<>(HttpStatus.NO_CONTENT);
        }

        URI uri = createLocationUri(EHR, ehrId.toString(), COMPOSITION, versionedObjectUid);

        List headerList = Arrays.asList(
                LOCATION,
                ETAG,
                LAST_MODIFIED); // whatever is required by REST spec - CONTENT_TYPE only needed for 200, so handled
        // separately

        Optional> respData = buildCompositionResponseData(
                ehrId,
                compositionUid,
                version,
                responseRepresentation,
                uri,
                headerList,
                () -> new CompositionResponseData(null, null));

        // returns 200 with body + headers, 204 only with headers or 500 error depending on what processing above yields
        return respData.map(i -> Optional.ofNullable(i.getResponseData().getValue())
                        .map(j -> ResponseEntity.ok().headers(i.getHeaders()).body(j))
                        // when the body is empty
                        .orElse(ResponseEntity.noContent()
                                .headers(i.getHeaders())
                                .build()))
                // when no response could be created at all
                .orElse(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
    }

    /**
     * Builder method to prepare appropriate HTTP response. Flexible to either allow minimal or full
     * representation of resource.
     *
     * @param                     Type of the response body
     * @param ehrId                  ID of the affected EHR
     * @param compositionId          ID of the composition
     * @param version                0 if latest, otherwise integer of specific version.
     * @param responseRepresentation Response Content-Type and {@link org.ehrbase.openehr.sdk.response.dto.ehrscape.CompositionFormat}
     *                               the response should be delivered in, as given by request
     * @param uri                    Location of resource
     * @param headerList             List of headers to be set for response
     * @param factory                Lambda function to constructor of desired object
     * @return
     */
    private  Optional> buildCompositionResponseData(
            UUID ehrId,
            UUID compositionId,
            int version,
            CompositionRepresentation responseRepresentation,
            URI uri,
            List headerList,
            Supplier factory) {
        // create either CompositionResponseData or null (means no body, only headers incl. link to resource), via
        // lambda request
        T minimalOrRepresentation = factory.get();

        // do minimal scope steps
        // create and supplement headers with data depending on which headers are requested
        HttpHeaders respHeaders = new HttpHeaders();
        for (String header : headerList) {
            switch (header) {
                case LOCATION:
                    respHeaders.setLocation(uri);
                    break;
                case ETAG:
                    respHeaders.setETag(
                            "\"" + compositionId + "::" + systemService.getSystemId() + "::" + version + "\"");
                    break;
                case LAST_MODIFIED:
                    // TODO should be VERSION.commit_audit.time_committed.value which is not implemented yet - mock for
                    // now
                    respHeaders.setLastModified(123124442);
                    break;
                default:
                    // Ignore header
            }
        }

        String templateId = null;

        // if response data objects was created as "representation" do all task from wider scope, too
        // if (minimalOrRepresentation.getClass().equals(CompositionResponseData.class)) {     // TODO make
        // Optional.ofNull....
        if (minimalOrRepresentation != null) {
            // when this "if" is true the following casting can be executed and data manipulated by reference (handled
            // by temporary variable)
            CompositionResponseData objByReference = minimalOrRepresentation;

            CompositionDto compositionDto = compositionService
                    .retrieve(ehrId, compositionId, version)
                    .map(c -> CompositionService.from(ehrId, c))
                    .orElseThrow(() -> new ObjectNotFoundException(COMPOSITION, "Couldn't retrieve composition"));

            templateId = compositionDto.getTemplateId();

            StructuredString ss = compositionService.serialize(compositionDto, responseRepresentation.format);
            objByReference.setValue(ss.getValue());
            objByReference.setFormat(ss.getFormat());

            // finally set last header
            respHeaders.setContentType(responseRepresentation.mediaType);
        } // else continue with returning but without additional data from above, e.g. body

        if (isBlank(templateId)) {
            templateId = compositionService.retrieveTemplateId(compositionId);
        }

        respHeaders.addIfAbsent(EHRbaseHeader.TEMPLATE_ID, templateId);

        HttpRestContext.register(
                EHR_ID,
                ehrId,
                HttpRestContext.LOCATION,
                getLocationUrl(compositionId, ehrId, version),
                TEMPLATE_ID,
                templateId);

        return Optional.of(new InternalResponse<>(minimalOrRepresentation, respHeaders));
    }

    private Optional getUidFrom(Composition composition) {
        return Optional.ofNullable(composition.getUid()).map(ObjectId::toString);
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy