Skip to content
Advertisement

How do you replace sub entities with HATEOAS links?

I am trying to figure out how to process a series of results in my controller, yet still return links for inline sub resources.

when I hit the exposed repository endpoint, I get responses that look like the following:

{
    "_embedded": {
        "resources": [
            {
                "id": "b10a978a-8304-4849-9bc5-c33e4082a9f3",
                "creationTimestamp": null,
                "createdBy": null,
                "lastEntityUpdate": 1366212764045,
                "lastModifiedBy": null,
                "archive": true,
                "contractStatus": "OFF",
                "callsign": "108",
                "registration": "C-GRIK",
                "username": null,
                "latitude": 47.011356,
                "longitude": -65.438644,
                "esn": "70001108",
                "imageURL": null,
                "description": "AFF",
                "phoneNumber": null,
                "speed": 14.0,
                "heading": 22.0,
                "altitude": 104.98560333252,
                "trackingPositionTimestamp": 1307552845000,
                "positionTimestamp": 1307552845000,
                "positionSource": "LMC-SBD",
                "userPosition": false,
                "hourlyRate": null,
                "dailyRate": null,
                "alertSchedule": null,
                "note": null,
                "checkinTime": null,
                "defaultCheckinInterval": null,
                "capacity": 0.0,
                "tracking": null,
                "lostContact": null,
                "currentContractId": null,
                "hireable": false,
                "supplierNumber": null,
                "siteNumber": null,
                "postalCode": null,
                "homePhoneNumber": null,
                "cellPhoneNumber": null,
                "hemanAgreementNumber": null,
                "displayLabel": "C-GRIK",
                "_links": {
                    "self": {
                        "href": "http://localhost:8080/resource-api/v1/resources/b10a978a-8304-4849-9bc5-c33e4082a9f3"
                    },
                    "selkirkResource": {
                        "href": "http://localhost:8080/resource-api/v1/resources/b10a978a-8304-4849-9bc5-c33e4082a9f3"
                    },
                    "homeOrg": {
                        "href": "https://example.com/dispatch/rest/organizations/2ef519bc-3353-4d6d-b935-a756d39e5b9f"
                    },
                    "carrier": {
                        "href": "http://localhost:8080/resource-api/v1/carriers/9c78a84d-e2dd-48b6-a328-aa78549a91d2",
                        "title": "Conair Group Inc."
                    },
                    "alertStatus": {
                        "href": "http://localhost:8080/resource-api/v1/alertStatuses/6b44353a-c650-43a0-aaad-299c798c85f2",
                        "title": "GREEN"
                    },
                    "resourceStatus": {
                        "href": "http://localhost:8080/resource-api/v1/resourceStatuses/e4b41c84-e5b8-4b0f-adf1-684980d0ac98",
                        "title": "OFF DUTY"
                    },
                    "type": {
                        "href": "http://localhost:8080/resource-api/v1/types/378b3ad1-d2e0-4975-9186-df81b4a362b3",
                        "title": "Birddog"
                    }
                }
            },
            {
                "id": "65122dc0-0121-4b73-8f9c-4f1002da3243",
                "creationTimestamp": null,
                "createdBy": null,
                "lastEntityUpdate": 1366992331928,
                "lastModifiedBy": null,
                "archive": true,
                "contractStatus": "OFF",
                "callsign": "Noltcho,Hart",
                "registration": "Noltcho,Hart",
                "username": null,
                "latitude": 48.0,
                "longitude": -148.0,
                "esn": null,
                "imageURL": null,
                "description": null,
                "phoneNumber": null,
                "speed": 0.0,
                "heading": 0.0,
                "altitude": 0.0,
                "trackingPositionTimestamp": null,
                "positionTimestamp": 1366131487406,
                "positionSource": "User",
                "userPosition": true,
                "hourlyRate": null,
                "dailyRate": null,
                "alertSchedule": null,
                "note": null,
                "checkinTime": null,
                "defaultCheckinInterval": null,
                "capacity": 0.0,
                "tracking": null,
                "lostContact": null,
                "currentContractId": null,
                "hireable": false,
                "supplierNumber": null,
                "siteNumber": null,
                "postalCode": null,
                "homePhoneNumber": null,
                "cellPhoneNumber": null,
                "hemanAgreementNumber": null,
                "displayLabel": "Noltcho,Hart",
                "_links": {
                    "self": {
                        "href": "http://localhost:8080/resource-api/v1/resources/65122dc0-0121-4b73-8f9c-4f1002da3243"
                    },
                    "selkirkResource": {
                        "href": "http://localhost:8080/resource-api/v1/resources/65122dc0-0121-4b73-8f9c-4f1002da3243"
                    },
                    "homeOrg": {
                        "href": "https://example.com/dispatch/rest/organizations/afbccc45-6ed7-40b5-ab99-1e9a1f71996f"
                    },
                    "resourceStatus": {
                        "href": "http://localhost:8080/resource-api/v1/resourceStatuses/e4b41c84-e5b8-4b0f-adf1-684980d0ac98",
                        "title": "OFF DUTY"
                    },
                    "type": {
                        "href": "http://localhost:8080/resource-api/v1/types/0c43e4bf-eae7-405c-a0d9-7bddd378cb10",
                        "title": "Type 1 Member"
                    }
                }
            }
        ]
    },
    "_links": {
        "first": {
            "href": "http://localhost:8080/resource-api/v1/resources?page=0&size=2&sort=lastEntityUpdate,asc"
        },
        "self": {
            "href": "http://localhost:8080/resource-api/v1/resources?size=2&sort=lastEntityUpdate,asc"
        },
        "next": {
            "href": "http://localhost:8080/resource-api/v1/resources?page=1&size=2&sort=lastEntityUpdate,asc"
        },
        "last": {
            "href": "http://localhost:8080/resource-api/v1/resources?page=2788&size=2&sort=lastEntityUpdate,asc"
        },
        "profile": {
            "href": "http://localhost:8080/resource-api/v1/profile/resources"
        },
        "search": {
            "href": "http://localhost:8080/resource-api/v1/resources/search"
        }
    },
    "page": {
        "size": 2,
        "totalElements": 5578,
        "totalPages": 2789,
        "number": 0
    }
}

however when I hit my cached version of the entity, I get the following

{
    "_embedded": {
        "resources": [
            {
                "id": "b10a978a-8304-4849-9bc5-c33e4082a9f3",
                "creationTimestamp": null,
                "createdBy": null,
                "lastEntityUpdate": 1366212764045,
                "lastModifiedBy": null,
                "archive": true,
                "type": {
                    "id": "378b3ad1-d2e0-4975-9186-df81b4a362b3",
                    "displayLabel": "Birddog",
                    "lastEntityUpdate": 1366212758677,
                    "lastModifiedBy": null,
                    "archive": false,
                    "parent": {
                        "id": "4dd05765-9fe6-441a-b2bc-d3199e3d69e0",
                        "displayLabel": "Fixed Wing",
                        "lastEntityUpdate": 1366212794520,
                        "lastModifiedBy": null,
                        "archive": false,
                        "parent": {
                            "id": "fe90eedb-9ae4-422c-84aa-ac55a0900fbf",
                            "displayLabel": "Aircraft",
                            "lastEntityUpdate": 1366212749915,
                            "lastModifiedBy": null,
                            "archive": false,
                            "parent": null,
                            "pathLabel": "Aircraft",
                            "nodeDepth": 0,
                            "defaultCheckinInterval": 1800000,
                            "iconURL": null,
                            "hourlyRate": 0.0,
                            "dailyRate": null,
                            "reportSituation": false
                        },
                        "pathLabel": "Aircraft:Fixed Wing",
                        "nodeDepth": 1,
                        "defaultCheckinInterval": 1800000,
                        "iconURL": null,
                        "hourlyRate": 0.0,
                        "dailyRate": null,
                        "reportSituation": false
                    },
                    "pathLabel": "Aircraft:Fixed Wing:Birddog",
                    "nodeDepth": 2,
                    "defaultCheckinInterval": 1800000,
                    "iconURL": null,
                    "hourlyRate": 0.0,
                    "dailyRate": null,
                    "reportSituation": true
                },
                "makeModel": null,
                "alertStatus": {
                    "id": "6b44353a-c650-43a0-aaad-299c798c85f2",
                    "displayLabel": "GREEN",
                    "lastEntityUpdate": 1300921860738,
                    "lastModifiedBy": null,
                    "archive": false,
                    "colorCode": null,
                    "sortIndex": null
                },
                "carrier": {
                    "id": "9c78a84d-e2dd-48b6-a328-aa78549a91d2",
                    "displayLabel": "Conair Group Inc.",
                    "lastEntityUpdate": 1446148848536,
                    "lastModifiedBy": null,
                    "archive": false
                },
                "resourceStatus": {
                    "id": "e4b41c84-e5b8-4b0f-adf1-684980d0ac98",
                    "displayLabel": "OFF DUTY",
                    "lastEntityUpdate": 1300832420067,
                    "lastModifiedBy": null,
                    "archive": false,
                    "clearCheckin": true
                },
                "contractStatus": "OFF",
                "callsign": "108",
                "registration": "C-GRIK",
                "username": null,
                "latitude": 47.011356,
                "longitude": -65.438644,
                "esn": "70001108",
                "imageURL": null,
                "description": "AFF",
                "phoneNumber": null,
                "speed": 14.0,
                "heading": 22.0,
                "altitude": 104.98560333252,
                "trackingPositionTimestamp": 1307552845000,
                "positionTimestamp": 1307552845000,
                "positionSource": "LMC-SBD",
                "userPosition": false,
                "hourlyRate": null,
                "dailyRate": null,
                "alertSchedule": null,
                "note": null,
                "checkinTime": null,
                "defaultCheckinInterval": null,
                "capacity": 0.0,
                "tracking": null,
                "lostContact": null,
                "currentContractId": null,
                "hireable": false,
                "supplierNumber": null,
                "siteNumber": null,
                "postalCode": null,
                "homePhoneNumber": null,
                "cellPhoneNumber": null,
                "hemanAgreementNumber": null,
                "displayLabel": "C-GRIK",
                "_links": {
                    "self": {
                        "href": "http://localhost:8080/resource-api/v1/resources/b10a978a-8304-4849-9bc5-c33e4082a9f3"
                    },
                    "homeOrg": {
                        "href": "https://example.com/dispatch/rest/organizations/2ef519bc-3353-4d6d-b935-a756d39e5b9f"
                    }
                }
            },
            {
                "id": "65122dc0-0121-4b73-8f9c-4f1002da3243",
                "creationTimestamp": null,
                "createdBy": null,
                "lastEntityUpdate": 1366992331928,
                "lastModifiedBy": null,
                "archive": true,
                "type": {
                    "id": "0c43e4bf-eae7-405c-a0d9-7bddd378cb10",
                    "displayLabel": "Type 1 Member",
                    "lastEntityUpdate": 1366992330601,
                    "lastModifiedBy": null,
                    "archive": false,
                    "parent": {
                        "id": "96c7d47c-e783-4a8b-9fe3-3eed6a14cb66",
                        "displayLabel": "Crew",
                        "lastEntityUpdate": 1351104068068,
                        "lastModifiedBy": null,
                        "archive": false,
                        "parent": null,
                        "pathLabel": "Crew",
                        "nodeDepth": 0,
                        "defaultCheckinInterval": 0,
                        "iconURL": "/crew.resource.16.png",
                        "hourlyRate": 0.0,
                        "dailyRate": null,
                        "reportSituation": false
                    },
                    "pathLabel": "Crew:Type 1 Member",
                    "nodeDepth": 1,
                    "defaultCheckinInterval": 0,
                    "iconURL": "/crew.resource.16.png",
                    "hourlyRate": 0.0,
                    "dailyRate": null,
                    "reportSituation": true
                },
                "makeModel": null,
                "alertStatus": null,
                "carrier": null,
                "resourceStatus": {
                    "id": "e4b41c84-e5b8-4b0f-adf1-684980d0ac98",
                    "displayLabel": "OFF DUTY",
                    "lastEntityUpdate": 1300832420067,
                    "lastModifiedBy": null,
                    "archive": false,
                    "clearCheckin": true
                },
                "contractStatus": "OFF",
                "callsign": "Noltcho,Hart",
                "registration": "Noltcho,Hart",
                "username": null,
                "latitude": 48.0,
                "longitude": -148.0,
                "esn": null,
                "imageURL": null,
                "description": null,
                "phoneNumber": null,
                "speed": 0.0,
                "heading": 0.0,
                "altitude": 0.0,
                "trackingPositionTimestamp": null,
                "positionTimestamp": 1366131487406,
                "positionSource": "User",
                "userPosition": true,
                "hourlyRate": null,
                "dailyRate": null,
                "alertSchedule": null,
                "note": null,
                "checkinTime": null,
                "defaultCheckinInterval": null,
                "capacity": 0.0,
                "tracking": null,
                "lostContact": null,
                "currentContractId": null,
                "hireable": false,
                "supplierNumber": null,
                "siteNumber": null,
                "postalCode": null,
                "homePhoneNumber": null,
                "cellPhoneNumber": null,
                "hemanAgreementNumber": null,
                "displayLabel": "Noltcho,Hart",
                "_links": {
                    "self": {
                        "href": "http://localhost:8080/resource-api/v1/resources/65122dc0-0121-4b73-8f9c-4f1002da3243"
                    },
                    "homeOrg": {
                        "href": "https://example.com/dispatch/rest/organizations/afbccc45-6ed7-40b5-ab99-1e9a1f71996f"
                    }
                }
            }
        ]
    },
    "_links": {
        "first": {
            "href": "http://localhost:8080/resource-api/v1/resources/getAllCached?page=0&size=2"
        },
        "self": {
            "href": "http://localhost:8080/resource-api/v1/resources/getAllCached"
        },
        "next": {
            "href": "http://localhost:8080/resource-api/v1/resources/getAllCached?page=1&size=2"
        },
        "last": {
            "href": "http://localhost:8080/resource-api/v1/resources/getAllCached?page=2788&size=2"
        }
    },
    "page": {
        "size": 2,
        "totalElements": 5578,
        "totalPages": 2789,
        "number": 0
    }
}

any idea how I can return something that looks like the former?

below is how I a preparing my controller’s response:

    private ResponseEntity<PagedModel<CollectionModel<MyEntity>>> buildResponse(List<MyEntity> filteredList, Pageable pageable, Link link){

        filteredList.sort(new PageableComparator<>(pageable));
        int size = pageable.getPageSize();
        int offset = pageable.getPageNumber() * size;
        int end = Math.min((offset + size), filteredList.size());
        List<MyEntity> pageElements = filteredList.subList(offset, end);

        Page page = new PageImpl(pageElements, pageable, filteredCache.size());

        if (pageElements.size() == 0) {
            throw new GeneralControllerException("No resources match your query", HttpStatus.NOT_FOUND);
        }

        return ResponseEntity.ok(pagedResourcesAssembler.toModel(page,myEntityRepresentationModelAssembler,link));

    }

Advertisement

Answer

I ended up having to build my own annotation and use it in conjunction with @JsonProperty(access=Access.WRITE_ONLY) And I built a rudimentary introspection-based processor.

This is not particularly elegant, nor is it complete, but it works for my current use cases which involve all subentities having a UUID id and most having a derived getter for displayLabel:

import com.selkirksystems.hateoas.annotation.RenderAsInternalMicroserviceHateoasLink;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.data.rest.webmvc.PersistentEntityResource;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.server.EntityLinks;
import org.springframework.hateoas.server.RepresentationModelProcessor;
import org.springframework.http.HttpStatus;

import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

public class InternalEntityLinksResourceProcessor<T> implements RepresentationModelProcessor<EntityModel<T>> {

    private final EntityLinks entityLinks;
    private static final ConcurrentHashMap<Class<?>, ConcurrentHashMap<String, Method>> allMethods = new ConcurrentHashMap<>();
    private final Log logger = LogFactory.getLog(InternalEntityLinksResourceProcessor.class);

    public InternalEntityLinksResourceProcessor(EntityLinks entityLinks) {
        this.entityLinks = entityLinks;
    }

    private void addInternalEntityLinks(EntityModel<T> resource) throws Exception {
        try {
            T resourceContent = resource.getContent();
            for (Field field : Objects.requireNonNull(resourceContent).getClass().getDeclaredFields()) {
                field.setAccessible(true);
                RenderAsInternalMicroserviceHateoasLink annotation = field.getAnnotation(RenderAsInternalMicroserviceHateoasLink.class);
                if (annotation != null) {
                    addLinkForField(field, resource);
                }
            }
        } catch (Exception ex) {
            throw new Exception("Error while adding external microservice references", ex);
        }
    }

    private void addLinkForField(Field field, EntityModel<T> resource) throws Exception {
        if (!(resource instanceof PersistentEntityResource)) { //dirty hack #27, prevent double adding links
            Object o = field.get(resource.getContent());
            if (o != null) {
                UUID uuid = (UUID) getValue(o, "id");
                if (uuid == null) {
                    throw new Exception(field.getType().getName() + " Does not have a UUID field named id.  It does not support the RenderAsInternalMicroserviceHateoasLink annotation");
                }
                Link link = entityLinks.linkToItemResource(field.getType(), uuid);
                String title = (String) getValue(o, "displayLabel");
                if (title != null) {
                    link = link.withTitle(title);
                }
                resource.add(link);
            }

        }

    }

    private Object getValue(Object o, String property) throws IntrospectionException, InvocationTargetException, IllegalAccessException {
        Method m = getGetter(o, property);
        if (m == null) return null;
        return m.invoke(o);
    }

    private Method getGetter(Object o, String property) throws IntrospectionException {
        ConcurrentHashMap<String, Method> methods = allMethods.computeIfAbsent(o.getClass(), k -> new ConcurrentHashMap<>());
        Method m = methods.get(property.toUpperCase());

        if (m != null) {
            return m;
        }

        PropertyDescriptor[] pds = Introspector.getBeanInfo(o.getClass(), Object.class).getPropertyDescriptors();
        for (PropertyDescriptor pd : pds) {
            if (pd.getName().equalsIgnoreCase(property.toUpperCase())) {
                m =pd.getReadMethod();
            }
            methods.put(pd.getName().toUpperCase(), pd.getReadMethod());
        }

        if (m == null) {
            throw new IntrospectionException("Property " + property + " does not exist");
        }

        return m;
    }


    @Override
    public EntityModel<T> process(EntityModel<T> model) {
        try {
            addInternalEntityLinks(model);
        } catch (Exception ex) {
            logger.error(ex, ex);
            throw new MyHateoasException("Error converting sub-entities to links: " + ex.getMessage(), HttpStatus.BAD_REQUEST);
        }
        return model;
    }
}
User contributions licensed under: CC BY-SA
9 People found this is helpful
Advertisement