0

I have a RESTful service where clients provide product codes, start date and end date. In response, the service returns a list of price metrics for the products over the date range. Start date is optional. If not provided, price metrics for standard periods are returned.

The data is captured in PriceMetricsDto objects and formatted & returned to the client as JSON:

@Builder
@Data
public class PriceMetricsDto{

    private String productCode;
    private String productClass;
    private String productFee;
    private String currency;
    
    //has a value only when start date provided
    private BigDecimal customAnnualAverage;

    //these properties are null when start date is provided
    private BigDecimal threeYearAnnualAverage;
    private BigDecimal fiveYearAnnualAverage;
    private BigDecimal tenYearAnnualAverage;
    private BigDecimal fifteenYearAnnualAverage;
    private BigDecimal twentyYearAnnualAverage;

    //has a value only when start date provided     
    private BigDecimal customTotal;

    //these properties are null when start date is provided
    private BigDecimal sixMonthTotal;
    private BigDecimal oneYearTotal;    
    private BigDecimal threeYearTotal;
    private BigDecimal fiveYearTotal;
    private BigDecimal tenYearTotal;
    private BigDecimal fifteenYearTotal;
    private BigDecimal twentyYearTotal;

}

customAnnualAverage and customTotal have a value only when start date is provided. (Note: if no product codes are specified, all products are returned) For example,

http://localhost/priceMetrics?startDate=2020-01-01&endDate=2021-02-28

All of the xxxxAnnualAverage properties have a value only when no start date is provided. For exmaple,

http://localhost/priceMetrics?endDate=2021-02-28

Does this follow the standard way for building a RESTful API under this type of scenario (i.e. optional param dictating population of certain properties)? Or should I break into two domain objects, PriceMetricCustomDateDto and PriceMetricStandardDateDto with different REST endpoints? For example:

http://localhost/priceMetricsCustomDate?startDate=2020-01-01&endDate=2021-02-28

and

http://localhost/priceMetricsStandardDatePeriods?endDate=2021-02-28
4

Does this follow the standard way for building a RESTful API under this type of scenario

Yup, that's fine. As far as REST is concerned, if the identifiers are different, then the resources are different. REST does not imply that two resources with similar identifiers will have similar representations.

http://localhost/priceMetrics?startDate=2020-01-01&endDate=2021-02-28
http://localhost/priceMetrics?endDate=2021-02-28

These identify two different resources, and there's no particular reason to assume that the resources are similar.

There's no coupling between identifiers and message schemas (hint: URL shorteners work).

27
  • The two resources you listed are the same resource, just with start date query parameter missing.
    – Blaž Mrak
    Jun 16 at 16:41
  • 2
    No. They are different resources, that might just happen to have overlapping implementations. See ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm , datatracker.ietf.org/doc/html/rfc7234, etc. The identifier includes the query part. See: datatracker.ietf.org/doc/html/rfc3986 Jun 16 at 17:12
  • Query parameters are called query parameters, because they query the resources. Am I missing something? They help you filter the representation on a certain path, not define the output by their presence or absence.
    – Blaž Mrak
    Jun 16 at 17:38
  • Yes, you are missing something. See datatracker.ietf.org/doc/html/rfc3986#section-3.4 Jun 16 at 18:00
  • 1
    @Laiv You can easily do that with a query parameter fields=field1,field2 for example. What I am saying is that the query parameters help you filter and define the representation. With HATEOAS you would fetch reduced version by going to different hierarchy, not by defining or not defining a query parameter. The interpretation of the author stretches the URI definition to the point of breaking basically. It is the same as putting a baby in the microwave and saying that the instructions did not forbid that, when instructions say that whatever you put in, it will cook.
    – Blaž Mrak
    Jun 17 at 23:02
1

I like the solution provided by @blaž-mrak, as it really simple, and allows the front-end to decide what they really want.

However, if you where to actually allow requesting several calculations at once, here is an idea on how to do so:

By the way your code looks, you probably have some logic in your application that looks kind of like (let's call the class containing that logic Controller):

PriceMetricsDto dto = new PriceMetricsDto();
if (startDate != null) {
    dto.setCustomAnnualAverage(calculateAnnualAverage(startDate, endDate, productDetails))
    dto.setCustomTotal(calculateTotal(startDate, endDate, productDetails))
} else {
    dto.setThreeYearAnnualAverage(calculateThreeYearAnnualAverage(endDate, productDetails))
    dto.setFiveYearAnnualAverage(calculateFiveYearAnnualAverage(endDate, productDetails))
    dto.setTenYearAnnualAverage(calculateTenYearAnnualAverage(endDate, productDetails))
    dto.setFifteenYearAnnualAverage(calculateFifteenYearAnnualAverage(endDate, productDetails))
    dto.setTwentyYearAnnualAverage(calculateTwentyYearAnnualAverage(endDate, productDetails))

    // Same with totals
}

Now, the idea to remove this similar and duplicated code is as follows:

Your DTO could have to collections of BigDecimals, one for averages and another for totals. And those values would be calculated implementing the strategy pattern, and allowing the front-end to decide which values it actually wants.

So, your DTO would be something like:

@Data
public class PriceMetricsDto {
    private String productCode;
    private String productClass;
    private String productFee;
    private String currency;
    
    private EnumMap<PeriodAverageEnum, BigDecimal> periodAverages = new EnumMap<>(PeriodAverageEnum.class);
    private EnumMap<PeriodTotalEnum, BigDecimal> periodTotals = new EnumMap<>(PeriodTotalEnum.class);
}

Where PeriodAverageEnum and PeriodTotalEnum would be as such:

public enum PeriodAverageEnum {
    CUSTON_PERIOD (() -> new CustomAverage()),
    THREE_YEARS (() -> new ThreeYearsAverage()),
    FIVE_YEARS (() -> new FiveYearsAverage()),
    TEN_YEARS (() -> new TenYearsAverage());
//    ...


    private final Supplier<IPeriodAverageStrategy> supplier;

    private PeriodAverageEnum(Supplier<IPeriodAverageStrategy> supplier) {
        this.supplier = supplier;
    }

    public IPeriodAverageStrategy getStrategy() {
        return supplier.get();
    }
}

(PeriodTotalEnum would be really similar, so I'm going to omit it) And you would have two interfaces IPeriodAverageStrategy and IPeriodTotalStrategy such as:

public interface IPeriodAverageStrategy {
    BigDecimal calculateAverage(Date startDate, Date endDate, ProductDetails productDetails);
}

This way, you would have different strategies for each of the requested values. For example:

public class ThreeYearsAverage implements IPeriodAverageStrategy {

    @Override
    public BigDecimal calculateAverage(Date startDate, Date endDate, ProductDetails producDetails) {
        // do your stuff here
        return calculateAnnualAverage(startDate, endDate, productDetails);
    }
}

And last but not least, Controller would now have a method such as:

public PriceMetricsDto getPriceMetrics(Date startDate, Date endDate, 
        Set<PeriodAverageEnum> periodAveragesDesired, Set<PeriodTotalEnum> periodTotalsDesired) {
    // Get you product details somewhere...
    ProductDetails productDetails = new ProductDetails();
    // Build your dto with the needed product details...
    PriceMetricsDto dto = new PriceMetricsDto();
    
    EnumMap<PeriodAverageEnum, BigDecimal> periodAvgs = dto.getPeriodAverages();
    for (PeriodAverageEnum periodAvgDesired : periodAveragesDesired) {
        periodAvgs.put(periodAvgDesired, periodAvgDesired.getStrategy().calculateAverage(startDate, endDate, productDetails));
    }
    
    // Note this code is kind of the same as the above. It could be extracted using generics
    EnumMap<PeriodTotalEnum, BigDecimal> periodTotals = dto.getPeriodTotals();
    for (PeriodTotalEnum periodTotalDesired : periodTotalsDesired) {
        periodTotals.put(periodTotalDesired, periodTotalDesired.getStrategy().calculateTotal(startDate, endDate, productDetails));
    }
    
    return dto;
}

All this being said, the front-end could now make requests such as:

SOME_END_POINT?startDate=2020-01-01&endDate=2021-02-28&periodAveragesDesired=CUSTON_PERIOD&periodAveragesDesired=THREE_YEARS
SOME_END_POINT?endDate=2021-02-28&periodTotalsDesired=SIX_MONTHS
...

in which the front-end would, with just one request, retrieve all data needed.

If you wanted to further improve this, you could add default values to periodAveragesDesired and periodTotalsDesired, so the front-end does not need to bother about them unless it really needs something specific.

All this being said, unless this type of solution is absolutely necesary for your use case, the KISS principle rules. So I'd follow @blaž-mrak's advice

0

You are having trouble, because you are concerning yourself with the problems of the front end. Create a single endpoint GET /priceMetrics?startDate&endDate, which returns:

public class PriceMetricsDto{

    private String productCode;
    private String productClass;
    private String productFee;
    private String currency;

    private Date startDate;
    private Date endDate;
   
    private BigDecimal average;
    private BigDecimal total;
}

The front end will either call this endpoint multiple times or you can make startDate and endDate arrays of dates if the client wants more than one total. This batching is an optimization which might not even be needed.

If you decide to process batches, your endpoint will return List and the client will have to arrange them then by the start and end date (which is why I duplicated the fields in the DTO).

Note: even the average is not needed -> the client can calculate it by taking the total and dividing by differenceInDays(endDate, startDate).

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.