Slack Java API and the Data Non Problem
Recently I had the "joy" to work with the Slack Java API. I felt that this Java API is tedious and an example of an API which create busy work for little benefit. Let me show first how the API is used:
var slack = Slack.getInstance();
var methods = slack.methods();
var channeId = "C424242";
var request = ChatPostMessageRequest.builder()
.channel(channeId)
.text(":wave: Hi from a bot written in Java!")
.build();
var response = methods.chatPostMessage(request);
You see that we create a message builder, build up a message up, and then call a specific method. You might think, oh that doesn’t look that bad. It is a nice builder and a decent API. Yes, it does not look bad at first sight. All right, let’s do a bit more complicated message:
var section = SectionBlock.builder().text(
PlainTextObject.builder().text("Some Section").build()).build();
var block = ImageBlock.builder()
.imageUrl("some-url")
.altText("A description")
.imageHeight(100).imageWidth(100)
.build();
var ui = new ArrayList<LayoutBlock>();
ui.add(section);
ui.add(block);
var request = ChatPostMessageRequest.builder()
.channel(C1234567) // Use a channel ID `C1234567` is preferrable
.blocks(ui
.build();
Hmm…a bit wordy, but not that bad, right? Maybe, but you know what this turns into at the end? In turns into a JSON message. What does the Slack documentation use in its examples? JSON! What do error messages from the Slack endpoint refer to? The JSON fields!
So, I read the documentation, examples, errors in JSON, and then I have to figure out how to create that simple JSON file with all these factories. Look at this small example JSON:
[
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Danny Torrence left the following review for your property:"
}
},
{
"type": "section",
"block_id": "section567",
"text": {
"type": "mrkdwn",
"text": "<https://example.com|Overlook Hotel> \n :star: \n Doors had too many axe holes, guest in room 237 was far too rowdy, whole place felt stuck in the 1920s."
},
"accessory": {
"type": "image",
"image_url": "https://is5-ssl.mzstatic.com/image/thumb/Purple3/v4/d3/72/5c/d3725c8f-c642-5d69-1904-aa36e4297885/source/256x256bb.jpg",
"alt_text": "Haunted hotel image"
}
},
{
"type": "section",
"block_id": "section789",
"fields": [
{
"type": "mrkdwn",
"text": "*Average Rating*\n1.0"
}
]
}
]
Do you know how you build this up with the builders? Good luck! You probably will figure it out eventually. However, how much time would you need to build up the JSON? Probably not much time, it is a 1:1 translation. For example, with a library that takes maps (example rest assured library) or has another generic JSON support, we can translate the documentation directly. Here an example translating the documentation above:
var sections = List.of(
Map.of("type", "section",
"text", Map.of("type", "mrkdwn",
"text", "Danny Torrence left the following review for your property:")),
Map.of("type", "section",
"block_id", "section567",
"text", Map.of("type", "mrkdwn",
"text", "....")
/* and so on */
)
);
Let’s go one step further. What if we build up these messages dynamically? Now you have to figure out how to pass these Slack Java API builders around in your code and build it up incrementally. With maps you have no problem, maybe you get it once or twice wrong, but it is easy to inspect. Can you inspect the builders? Can you use your existing library and utility functions for the Slack Java API? Nope. For maps you already have a gigantic library in the JDK. Do you need mocks in tests for the actual HTTP requests? I can easily check and compare maps or JSON. Not so sure how I should compare the builder and the built objects. And for that API I also pay heavy dependencies: A JSON library, an HTTP library, Kotlin standard library, and more. At this point I want to take a regular HTTP library and just do the HTTP request myself. It isn’t rocket science.
Fancy Mapping
Another area where I see this in using frameworks to map something like JSON/whatever to your objects. If you can map it 1:1 without much fuss, fine, go ahead. However, I often see complex mappings because the types in your code are don’t correspond 1:1 to a JSON type. Then it ends in an Annotation zoo.
// Welcome to the Annotation zoo @JsonLibSpecialRules(nameMapping=AcceptSnakeCasingToo) public class Person{ public String getName(); @JsonField("_id") public long getId(); @JsonLibSomethingSpecialHere public BloodType getBloodType(); @JsonLibAcceptIsoDate @JsonLibConvertNullToSomething public LocalDate getBirthDay(); } @JsonLibConvertWith(BloodTypeConverter.class) enum BloodType{ // ... } @JsonLibMoreSettings(singleton=true) @JsonLibCompanion(BloodTypeConverterCompanion.class) @JsonLibAcceptNulls class BloodTypeConverter implements MapperLibraryCoverter{ // Code } class BloodTypeConverterCompanion implements MapperSpecialCases{ // Code }
Do you understand all these Annotations? Does someone joining your project understand these? I sure don’t understand it. Do you know what I understand? Plain code. Use plain code for such mapping. You can go two ways about this: Either use types which map 1:1 to JSON and then fix the cases you need something extra. Like:
// 1:1 Json class public class PersonJson{ public String name; public int _id; public String blood_type; public String birth_day; // Converter code. Called in the Rest endpoints public Person toPerson(){ return new Person(name, _id, Enum.valueOf(BloodType.class, blood_type), LocalDateUtil.parseDate(birth_day, DEFAULT_DATE)); } public static PersonJson fromPerson(Person p){ // create the JSON version ... } }
I’ve no problem understanding this, and most programmers will understand this. Debugging this code is easy, you can step through it. You can have arbitrary rules for your conversion. No limits.
Or you know what, you can directly use the underlying JSON. It is not hard if you build up some helper methods.
// 1:1 Json class public class Person{ // Getter, setters etc // Converter code. Called in the Rest endpoints public static Person fromJson(Map<String,Object> json){ // Assuming Json methods are built up over time to reflect your typical things you need var name = Json.stringField(json, "name"); var id = Json.intField(json, "_id"); var bloodType = Json.enumField(json, "blood_type", BloodType.class, BloodType.Unknown) var birthDay = Json.dateField(json, "birth_day", DEFAULT_DATE) return new Person(name, id, bloodType, birthDay); } // Converter code. Called in the Rest endpoints public Map<String, Object> toJson(){ var json = new HashMap<String, Object>(); json.put("name", name); json.put("_id", id); json.put("blood_type", bloodType.name()); json.put("birth_day", Json.formatDate(birthDay)); } }
Now there is no magic anymore. You can see the JSON and debug every step. It is not that much more code than complex JSON mappings. It makes it also easy to support versions, restricted views, different flavors, etc. Yes, its certainly the mainstream way in the Java/C#, but I worked on codebases that used this pattern. I take it any time over a complex Annotation zoo. That said, if you don’t need an Annotation zoo, you don’t need to go this far out of your comfort zone.
The Data Non Problem
There are many more examples in Java/C# libraries that do this. They take a WEB, database, or some other endpoint that receives and sends data and create a gigantic API out of it. That API makes straight forward IO operations you could do with your standard library and tools and turn into their own gigantic API surface. You get a bit of initial convenience and then pay with more dependencies, inflexible, hard to test code, and probably even wrapper code to massage your apps needs onto this inflexible API.
Every time I see such an API I think of Rich Hickey’s rant on why he got tired of Java/C#/C++ and created Clojure. He calles this the data non problem. Here’s the part of the video (starting at 49 minutes):
Rich complains exactly about the issue where basic data is turned into tons of API surface so that you can’t use libraries on it, have to learn new APIs, couple your code to that API, and so on. If the data were just kept as data, you get more power with less code. We as programmers turn data which is easy to handle into in gigantic APIs and create a problems we didn’t have in the first place.
Support Data in Your API
Well, unlike Rich Hickeys you probably don’t have to luxury of creating your own programming language. So, what do you do? Any recommendations for APIs? Well, for Slack you can stick to their HTTP API and use your HTTP client of choice. If you design an API, consider accepting basic data. For example, accept a map with the raw data. Or have your builder build up a map, allowing mixing hardcoded pieces with the builder and flexible data passing with the map.
Here are a few examples from decent APIs. The Dapper database library for C# allows map, anonymous classes, and regular classes to pass arguments. It allows returning in your classes or dynamic objects:
// Dapper acceps map and can return a dynamic typed object.
var result = con.Query("select item,price from purchases where item like @item",
new Dictionary<string, object>
{
{"item", "older item"}
});
foreach (var r in result)
{
Console.Out.WriteLine(r.item + " " + r.price);
// The result implements a IDictionary<string, object>. You view it as a collection
var asDir = r as IDictionary<string, object>;
Console.Out.WriteLine("Is a dictionary? " + asDir);
}
// But Dapper accepts a class any time:
class Dog {
// Properties
}
var dog = connection.Query<Dog>("select Age = @Age, Id = @Id", new { Age = (int?)null, Id = guid });
Another example is the mentioned Rest Assured library. It accepts your Java POJO, but also support a straight Java Map.
// Using a POJO
Message message = new Message();
message.setMessage("My message");
given().
contentType("application/json").
body(message).
when().
post("/message");
// Using a Map
Map<String, Object> jsonAsMap = new HashMap<>();
jsonAsMap.put("message", "My message");
given().
contentType("application/json").
body(jsonAsMap).
when().
post("/message").
At least provide some "escape hatch". For example, most ORM mappers have a way to run raw SQL. Like JPAs createNativeQuery.
Summary
Do not ruin plain data with elaborate constructs in your code. Choose libraries that support passing raw data, like collections, maybe strings. A Java POJO, Scala case class, etc are convenient, but maybe you are better off having a plain map representing your data.