ballerina-platform / ballerina-library

The Ballerina Library
https://ballerina.io/learn/api-docs/ballerina/
Apache License 2.0
136 stars 58 forks source link

Programmatically adding (HATEOAS) links to responses #2753

Open shafreenAnfar opened 2 years ago

shafreenAnfar commented 2 years ago

At the moment this is how we can add links in Ballerina. Consider the below example.

service /snowpeak on new http:Listener(port) {

    resource function get locations() returns rep:Location[]|rep:SnowpeakInternalError {
       // some logic
    }

    resource function get locations/[string id]/rooms(string startDate, string endDate) 
                returns rep:Rooms|rep:SnowpeakInternalError {
        // some logic
    }
}

Now let's say you want to add a link to the response of the first resource to point to the second resource. In that case following things are needed to be done.

public type Location record {|
    *hateoas:Links;
    string name;
    string id;
    string address;
|};
resource function get location() returns rep:Location[]|rep:SnowpeakInternalError {
   return [{
      name: "Alps",
      id: "l1000",
      address: "NC 29384, some place, switzerland",
      _links: {
          rooms: {
             href: "/snowpeak/locations/l1000/rooms",
             methods: "GET"
          }
      }
   }]
}

Note: Definition of hateoas:Links


# Represents a server-provided hyperlink
public type Link record {
string href;
string rel?;
string[] types?;
Method[] methods?;
};

Represents available server-provided links

public type Links record {| map _links; |};


Even though this works, the main problem is relative URL in the `href` field is hardcoded. Doing so is error prone because in case there is a change, many places may need to be updated. Of course, having proper test cases could detect the failures but still you need to update many different places. 

Instead if there is way to refer to a resource using some identity, there is no need to hardcode the relative URL. Also other hardcoded fields such as `methods` and `type` should use the resource identity to fill their values.
shafreenAnfar commented 2 years ago

Following is how Spring Boot does.

Screen Shot 2022-03-01 at 11 11 00 PM
shafreenAnfar commented 2 years ago

On a slightly related note, apart from hardcoding the href value, in case someone is worried about polluting his/her entity recods with the hateoas:Links record, they can do the following.

type Person record {|
    string firstname;
    string lastname;
|};

type PersonRepresentation record {|
    *hateoas:Links;
    *Person;
|};

public function main() returns error? {
    Person p = { firstname: "Joe", lastname: "Biden"};
    PersonRepresentation pr = { ...p, _links: account: {href: "/org/dep/account"}}};
}
TharmiganK commented 2 years ago

In order to add links programmatically, we need a reference to the service object so that we can extract the path, method and return media-types. I could not find a way to get this reference directly in a service declaration. So the alternative way is to access it using a parameter of the resource function. Since we already have the service reference in RequestContext for the interceptor execution, RequestContext seems to be a good candidate for this.

The following new functions can be added to RequestContext :

# Defines the possible simple parameter types.
public type SimpleParamType boolean|int|float|decimal|string;

# Defines the path parameters used in link creation.
# + resourceName - resourceName which cannot be used as a path parameter
# + relation - relation which cannot be used as a path parameter
# + method - method which cannot be used as a path parameter
public type LinkPathParams record {|
    never resourceName?;
    never relation?;
    never method?;
    SimpleParamType...;
|};

public isolated class RequestContext {
    ...

    private map<Link>? links = ();

    ...

    # Adds a link to the context.
    #
    # + resourceName - The name of the resource specified in the `ResourceConfig` annotation   
    # + relation - The relation between the current context and the linked resource  
    # + method - The method of the linked resource
    # + pathParams - The path parameters
    # + return - Nil or an error if the link creation failed
    public isolated function addLink(string resourceName, string relation, string? method = (),
            *LinkPathParams pathParams) returns error? {
        // Errors :
        // Resource not found
        // Cannot resolve Resource without method
        // Duplicate relation found
        // Path params not found
        // Path param type not matched
        Link link = check externGetResourceLink(self, resourceName, method, pathParams);
        lock {
            self.links[relation] = link.clone();
        }
    }

    # Returns the links from the context.
    #
    # + return - The map of `Link` objects
    public isolated function getLinks() returns map<Link>? {
        lock {
            if self.links == {} {
                return ();
            }
            return self.links.clone();
        }
    }

    # Removes the links from the context.
    public isolated function removeLinks() {
        lock {
            self.links = {};
        }
    }
}

@shafreenAnfar @chamil321 WDYT? Is it ok to use the RequestContext for this purpose or should we use/introduce some other parameter?

chamil321 commented 2 years ago

In order to add links programmatically, we need a reference to the service object so that we can extract the path, method and return media-types. I could not find a way to get this reference directly in a service declaration. So the alternative way is to access it using a parameter of the resource function. Since we already have the service reference in RequestContext for the interceptor execution, RequestContext seems to be a good candidate for this.

The following new functions can be added to RequestContext :

# Defines the possible simple parameter types.
public type SimpleParamType boolean|int|float|decimal|string;

# Defines the path parameters used in link creation.
# + resourceName - resourceName which cannot be used as a path parameter
# + relation - relation which cannot be used as a path parameter
# + method - method which cannot be used as a path parameter
public type LinkPathParams record {|
    never resourceName?;
    never relation?;
    never method?;
    SimpleParamType...;
|};

public isolated class RequestContext {
    ...

    private map<Link> links = {};

    ...

    # Adds a link to the context.
    #
    # + resourceName - The name of the resource specified in the `ResourceConfig` annotation   
    # + relation - The relation between the current context and the linked resource  
    # + method - The method of the linked resource
    # + return - Nil or an error if the link creation failed
    public isolated function addLink(string resourceName, string relation, string? method = (),
            *LinkPathParams pathParams) returns error? {
        Link link = check externGetResourceLink(self, resourceName, method, pathParams);
        lock {
            self.links[relation] = link.clone();
        }
    }

    # Returns the links from the context.
    #
    # + return - The map of `Link` objects
    public isolated function getLinks() returns map<Link> {
        lock {
            return self.links.clone();
        }
    }

    # Removes the links from the context.
    public isolated function removeLinks() {
        lock {
            self.links = {};
        }
    }
}

@shafreenAnfar @chamil321 WDYT? Is it ok to use the RequestContext for this purpose or should we use/introduce some other parameter?

+1 RequestContext will be a good candidate as it contains necessary properties.

chamil321 commented 2 years ago

Or else, a bit simpler way without relating to other objects would be

type Person record {|
    string firstname;
    string lastname;
|};

service / on securedEP {
    resource function get person() returns record {|*Person; *hateoas:Links links;|} {
        hateoas:Links hotelLinks = http:createLinks("hotel", relation, GET, id = 5);
        return {firstname: "WSO2", lastname: "Ballerina", links: hotelLinks};
    }
}

Since this is an imperative way, user may change the return type along with links

TharmiganK commented 2 years ago

As discussed in the previous meeting, I was thinking of the following way which is similar to SpringBoot.

type Representation record {|
    *Links;
    anydata...;
|};

resource function get persons/[int id]() returns http:Representation|error {
    Person person = getPerson(id);
    // Creating an `EntityModel` using the person record. For this person record should be closed one
    // and  it should not contain `_links` field.
    http:EntityModel personModel= new(person);
    // Adding links to the model. This can return an error if link creation failed
    check personModel.addLink(relation1, resourceName1, method1?, ...PathParams1);
    check personModel.addLink(relation2, resourceName2, method2?, ...PathParams2);
    ...
    // Returning the representation from the entity. Since we don't support returning object type from
    // a resource, the developer should call this `getRepresentation`.
    return personModel.getRepresentation();
}

The user might want to return a custom representation type rather than http:Representation. Following is an example of returning user-defined representation :

type Person record {|
    string name;
    int age;
|};

type PersonRepresentation record {|
    *http:Links;
    *Person;
|};

resource function get persons/[int id]() returns PersonRepresentation|error {
    Person person = getPerson(id);
    http:EntityModel personModel= new(person);
    check personModel.addLink(relation1, resourceName1, method1?, ...PathParams1);
    check personModel.addLink(relation2, resourceName2, method2?, ...PathParams2);
    ...
    // The return type of `getRepresentation()`is same as the target type. And this function 
    // can return an error if the representation of entity is not matched with the target type
    return personModel.getRepresentation();
}

The following is the definition of EntityModel :

type SimpleParamType boolean|int|float|decimal|string;

type LinkPathParams record {|
    never resourceName?;
    never relation?;
    never method?;
    SimpleParamType...;
|};

class EntityModel {
    private map<Link> links;
    private Representation representation;

    public function init(record{|never _links?; anydata...;|} representation) {
        self.links = {};
        self.representation = {
            _links: {},
            ...representation
        };
    }

    public function getLinks() returns map<Link> {
        return self.links;
    }

    public isolated function getRepresentation(RepresentationType representationType = <>) 
           returns representationType|error = external;

    public isolated function addLink(string relation, string resourceName, string? method, 
           *LinkPathParams pathParams) returns error? = external;
}

Note : This only covers returning a single representation from a resource function. Need to think about returning a collection of representations.

@chamil321 @shafreenAnfar Please add your thoughts on this