Be
Kind
To Others

Designing
Developer-Friendly JSON
for API responses

 


Singapore PHP Community Combined Meetup 2018
Wed 26 Sep 2018
Introduction

Credits: https://media.balsamiq.com/img/examples/all-controls-sketch.png
Robustness Principle


Be conservative in what you send,
Be liberal in what you accept

Credits: https://mcartwork.org/wp-content/uploads/2017/01/sheep.png
Lingo...
  • JSON - name/value pairs, each pair being a property. Data interchange format
  • { "name": "Bob", "age": 20 }
  • An API consists of 1 or more endpoints, eg. https://example.com/products, https://example.com/merchants
1) camelCase for names
camelCase
{
    "firstName": "John",
    "lastName": "Doe"
}
class Person
{
    public $firstName;
    public $lastName;
}

$myBoss = new Person($json);
echo $myBoss->firstName;
echo $myBoss->lastName;
snake_case
{
    "first_name": "John",
    "last_name": "Doe"
}
class Person
{
    public $first_name;
    public $last_name;
}

$myBoss = new Person($json);
echo $myBoss->first_name;
echo $myBoss->last_name;
// camelCase
{
    "myList": [
        { "myName": "a" },
        { "myName": "b" },
        { "myName": "c" }
    ]
}
// snake_case
{
    "my_list": [
        { "my_name": "a"},
        { "my_name": "b"},
        { "my_name": "c"}
    ]
}
echo $post->ID . $post->post_title;
{
    "camelCase": 1,
    "snake_case": 2,
    "PascalCase": 3,
    "kebab-case-sounds-tasty": 4,
    "CAPS": 5,
    "sUpErCaLiFrAgIlIsTiCeXpIaLiDoCiOuS": 6
}
2) Use null only for objects, empty values for other data types
// PHP
$x = 3;
$x = null;
// Swift
var x: Int? = nil // allowed - Optional
var x: Int = nil  // not allowed

var y: Int? = 3
let a: Int = y! + 2 // allowed - unwrap Optional first
let b: Int = y + 2  // not allowed
// Java
int a = 1;
Integer b = 1;

int c = null;     // not allowed
Integer d = null; // allowed
// Swift
@objc
public class Person: NSObject, Codable {
    @objc public var id: Int = 0
    @objc public var name: String? = nil
    @objc public var address: Address? = nil
    @objc public var hobbies: [String]? = nil
    @objc public var age: Int? = nil // not allowed cos of @objc
}
{
    "id": 3,
    "name": "Bob",
    "address": {
        "street": "1 Alpha Ave",
        "zip": 123456
    },
    "hobbies": ["cycling", "swimming"],
    "age": null
}
http://php.net/manual/en/function.empty.php

The following values are considered to be empty:
  • "" (an empty string)
  • 0 (0 as an integer)
  • 0.0 (0 as a float)
  • null
  • false
  • array() (an empty array)
  • [] (an empty array)
What if 0 cannot be used?
{
  duration: -1,
  recordsPerPage: -1,
  age: -1
}
/* Different checks for null and empty string */

// PHP
$s = '';
echo (! $s) ? 'empty' : 'non-empty';
$s = null;
echo (! $s) ? 'empty' : 'non-empty'; // same check as for ''

// Java
String s = "";
System.out.println(s.equals("") ? "empty" : "non-empty"); // if s is null?
s = null; // s.equals() will throw NullPointerException
System.out.println(s == null ? "empty" : "non-empty");

// Swift
var s: String? = "" // cannot use String if value can be null
print(s == "" ? "empty" : "non-empty")
s = nil
print(s == nil ? "empty" : "non-empty")
3) Consistent data types
{
  "id": 1,
  "name": "John Doe",
  "spouse": {
    "id": "2",
    "name": "Jane Doe"
  }
}
// PHP
$x = 1;
$x = 'Hello';

// Java
int x = 1;
x = "Hello"; // not allowed

// Swift
let x: Int = 1
x = "Hello" // not allowed
{
  "children": [
    {
      "id": 1,
      "age": 3
    },
    {
      "id": 2,
      "age": 5
    }
  ]
}
{
  "children": {
    "id": 1,
    "age": 3
  }
}
{
  "children": []
}
https://example.com/products

{
  products: [],
  per_page: 10
}
https://example.com/products?per_page=10

{
  products: [],
  per_page: "10"
}
4) Use strings for id
Range for signed 64-bit integer: -2^63 to (2^63 - 1)
-9223372036854775808 to 9223372036854775807

Range for unsigned 64-bit integer: 0 to 2^64
0 to 18446744073709551616

Range for UUID (Universally unique identifier): 2^128
Example UUID:
123e4567-e89b-12d3-a456-426655440000
5) Do not omit properties
{
  "name": "Alice",
  "children": 3
}
{
  "name": "Bob"
}
@objc public class Person: NSObject, Codable { // Swift
    @objc public var id: Int = 0
    @objc public var name: String? = nil
    @objc public var address: Address? = nil
    @objc public var children: Int = 0
    // This entire init method could have been omitted
    required public init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        id = try values.decode(Int.self, forKey: .id)
        name = try values.decode(String?.self, forKey: .name)
        address = try values.decodeIfPresent(Address.self, forKey: .address)

        do {
            children = try values.decode(Int.self, forKey: .children)
        } catch _ {
            children = 0
        }
    }
}
let person = try JSONDecoder().decode(Person.self,
    from: jsonString.data(using: .utf8)
)
6) Same top-level properties across all endpoints
// Sample response from Person endpoint in API: https://example.com/people/321
{
  "status": "ok",
  "id": 321,
  "name": "John",
  "address": {
    "street": "Beta Ave",
    "zip": 123
  }
}

// Sample response from Products endpoint in API: https://example.com/products
{
  "status": "ok",
  "products": [
    {
      "id": 1,
      "name": "Air"
    }
  ],
  "recordsPerPage": 1,
  "page": 1,
  "total": 100
}
// Swift
@objc public class PersonResponse: NSObject, Codable {
    @objc public var status: String? = nil
    @objc public var id: Int = 0
    @objc public var name: String? = nil
    @objc public var address: Address? = nil
}

@objc public class Person: NSObject, Codable {
    @objc public var id: Int = 0
    @objc public var name: String? = nil
    @objc public var address: Address? = nil
}

@objc public class Address: NSObject, Codable {
    @objc public var street: String? = nil
    @objc public var zip: Int = 0
}
// Swift
@objc public class ProductsResponse: NSObject, Codable {
    @objc public var status: String? = nil
    @objc public var products: [Product]? = nil
    @objc public var recordsPerPage: Int = 0
    @objc public var page: Int = 0
    @objc public var total: Int = 0
}

@objc public class Product: NSObject, Codable {
    @objc public var id: Int = 0
    @objc public var name: String? = nil
}
// Swift generics - T would be PersonResponse or ProductResponse
func handleResponse<T: Codable>(
    completionHandler: @escaping (Response<T>) -> Void
) {
    // Get JSON response from some API and save as jsonString
    let response = try JSONDecoder().decode(T.self, from: jsonString.data(using: .utf8))
    completionHandler(response)
}

let responseHandler = { (response: Response<PersonResponse>) -> Void in
    print(response.status)
}
handleResponse(completionHandler: responseHandler)
// Suggested error response
{
  "data": null,
  "error": {
    "message": "Person not found"
  },
  "pagination": null
}
// Suggested response for Person endpoint
{
  "data": {
    "id": 321,
    "name": "John",
    "address": {
      "street": "Beta Ave",
      "zip": 123
    }
  },
  "error": null,
  "pagination": null
}
// Suggested response for Products endpoint
{
  "data": {
    "products": [
      {
        "id": 1,
        "name": "Air"
      }
    ]
  },
  "error": null,
  "pagination": {
    "recordsPerPage": 1,
    "page": 1,
    "total": 100
  }
}
7) Consistent naming for similar properties across all endpoints
// Response from Employee endpoint
{
  "id": 1,
  "lastName": "Woe",
  "age": 30,
  "spouse": {
    "personId": 15,
    "familyName": "Doe",
    "years": 29
  }
}

// Response from Manager endpoint
{
  "employeeId": 5,
  "surname": "Foe",
  "howOld": 40
}
8) Use UTC time zone and ISO8601 for timestamps
What would the following produce in PHP
when run at midnight on National Day?

echo date('d/m/y h:i A');

1) 09/08/18 00:00 AM
2) 08/08/18 04:00 PM
// All output is in ISO8601

// 2018-08-09T00:00:00+08:00 if server timezone is set to SGT
// 2018-08-08T16:00:00+00:00 if server timezone is set to UTC
echo date('c');

echo gmdate('c'); // always in GMT/UTC - 2018-08-08T16:00:00+00:00

$now = DateTimeImmutable::createFromFormat(
    'U.u',
    number_format(microtime(true), 6, '.', ''),
    new DateTimeZone('UTC')
);

echo $now->format('Y-m-d\TH:i:s.uP'); // in UTC - 2018-08-08T16:00:00.123456+00:00
{
    "posts": [
        { "id":1, "updated":1537511106 },
        { "id":2, "updated":1537531515 },
        { "id":3, "updated":1537533015 },
        { "id":4, "updated":1540125015 },
        { "id":5, "updated":2147483647 }
    ]
}
{
    "posts": [
        { "id":1, "updated":"2018-09-21T06:25:06+00:00" },
        { "id":2, "updated":"2018-09-21T12:05:15+00:00" },
        { "id":3, "updated":"2018-09-21T12:30:15+00:00" },
        { "id":4, "updated":"2018-10-21T12:30:15+00:00" },
        { "id":5, "updated":"2038-01-19T03:14:07+00:00" }
    ]
}
Which is easier to read?
9) Use 0, 1, -1 for boolean properties
// someFeature flag considered unspecified if value is -1 or flag is not set
{
  "featureFlags": {
    "someFeature": 1
  }
}
if (featureFlags.someFeature) { // easy to check
    // turn feature on
}

// `value` column uses TINYINT(1) data type
+----+-------------+-------+
| id | flag        | value |
+----+-------------+-------+
| 1  | someFeature | 1     |
+----+-------------+-------+
// Using boolean values: true, false
// What value to use to indicate unspecified/default behaviour?
{
  "featureFlags": {
    "someFeature": true
  }
}
if (featureFlags.someFeature) { // still easy to check
    // turn feature on
}

// MySQL has no BOOLEAN data type and uses TINYINT(1) instead
// API needs extra code to cast 1 to true, 0 to false
+----+-------------+-------+
| id | flag        | value |
+----+-------------+-------+
| 1  | someFeature | 1     |
+----+-------------+-------+
// Using string values: "on", "off", "yes", "no", "enabled", "disabled"
// What value to use to indicate unspecified/default behaviour?
{
  "featureFlags": {
    "someFeature": "on"
  }
}
// Hard to check
if ('on' === someFeature || 'yes' === someFeature || 'enabled' === someFeature) {
    // turn feature on
}

// API needs extra code to cast "on" to true, "off" to false
// Consumer of API response needs extra code to cast back "on" to true, etc.
+----+-------------+-------+
| id | flag        | value |
+----+-------------+-------+
| 1  | someFeature | on    |
+----+-------------+-------+

// Misspellings/miscapitalisation in code/JSON: "on", "ON", "On", "oN", "om", "no"
  • KISS principle – "Keep it Simple, Stupid"
  • Consistency



Credits: https://appsliced.co/app?n=frog-under-well-joyorange