EDAF75 – API for Krusty 2025

Overview

We're going to implement a REST(-ish) server for Krusty – with some simplifications:

  • We will omit some pretty obvious endpoints, when they're not needed for the rest of the project, and we're also going to assume that most of the input is correct (we want to focus on the database, not on error handling).
  • We're not going to do any authorization checks.
  • We're not going to implement an aspect of REST which is called Hypermedia As The Engine Of Application State (HATEOAS) – what we implement is sometimes called 'JSON-over-HTTP', but it's very common, and very useful.
  • We will not implement paging (if a request potentially returns hundreds or thousands of values, many REST services limit the number of returned values, and let the client fetch 'pages' of results instead of fetching everything at once).
  • When we create new data on our server, REST services typically return a URL to the newly created resource in the header of the response – to simplify things a bit, we'll instead return it in the response body as a JSON object (see below).

You can implement your service using either Python (Bottle) or Java (SparkJava), but we will require that you use constraints and/or triggers in the database to make sure that we don't produce more pallets than our ingredient store allows.

A test script can be downloaded here (save it as a Python file, and then run it).

URL encoding

We can send values to a REST server in several ways:

  • As parts of the URL (see below)
  • As parts of the request/response header
  • As parts of the request/response body (typically JSON objects)

Values sent in the header and body can be pretty much anything we want, but the set of characters allowed in a URL is restricted, so there are some rules we must abide by:

  • Query strings are sent as part of the URL, so there are characters we can't use in a search:
    • To search for the movie "Gosford Park" in our movie service in lab 3, we can't send the url

      curl -X GET http://localhost:7007/movies\?title=Gosford Park
      

      because the space is not allowed as part of a URL (and we can't put quotes around the string, because quotes aren't allowed either), and if we want to search for the movie "Äppelkriget" we can't send

      curl -X GET http://localhost:7007/movies\?title=Äppelkriget
      

      because the letter Ä is not allowed in a URL.

      In both these cases, we need to URL-encode our strings – that would turn "Gosford Park" into "Gosford%20Park", and "Äppelkriget" into "%C3%84ppelkriget", and a URL like

      curl -X GET http://localhost:7007/movies\?title=%C3%84nglar%2C%20finns%20dom%3F
      

      is valid (but we need to URL-decode the name on the server).

  • When we use a value as an identifier for a resource, it is sent as part of the URL. Assume that we in lab 3 had an endpoint:

    GET /theaters/<theater_name>
    

    with information about a theater – for the same reason as above, we can't search for information about "Röda kvarn" using:

    curl -X GET http://localhost:7007/theaters/Röda kvarn
    

    Instead we URL-encode the theater name into:

    curl -X GET http://localhost:7007/theaters/R%C3%B6da%20kvarn
    

    and let the server URL-decode the name into it's original value.

So, when we use query parameters and identities which will be sent as parts of a normal URL, we should make sure to URL-encode them on the client if there is a chance that they contain spurious characters – this means that we need to URL-decode the corresponding values on our server (to be safe, we can URL-encode/decode all query parameters and identities). We should also make sure that the server URL-encodes the URL's we return after a new value has been created on the server (it is sent back in the body of the response, so we could have sent it without encoding it, but then it may end up being an illegal URL).

We don't have to encode/decode values in the request header, or in the request body, it's only those strings which will be part of a URL which must be encoded/decoded.

BTW: observe that a regular string such as "regular" will be encoded as "regular" – only strings with special characters will effected when we encode them.

Fortunately there are good libraries for URL encoding in both Python and Java (see below).

URL encoding in Python

The following snippet encodes and decodes a string in Python:

from urllib.parse import quote, unquote

title = "Änglar, finns dom?"
url_encoded_title = quote(title)
print(f"{title} is encoded as {url_encoded_title} ...")
url_decoded_title = unquote(url_encoded_title)
print(f"... and we can get {url_decoded_title} back")

URL encoding in Java

The following snippet encodes and decodes a string using the Java standard library:

import java.net.URLEncoder;
import java.net.URLDecoder;

var title = "Änglar, finns dom?";
var urlEncodedTitle = URLEncoder.encode(title, "UTF-8");
System.out.printf("%s is encoded as %s ...\n", title, urlEncodedTitle);
var urlDecodedTitle = URLDecoder.decode(urlEncodedTitle, "UTF-8");
System.out.printf("... and we can get %s back\n", urlDecodedTitle);

A few words about keys in this project

For several of the entity sets in this project, we can choose between using a natural key, or inventing our own (artificial) key. The API below will make some things simpler when using natural keys, but that's not meant to keep you from using invented keys. Some of the potential natural keys are a bit unwieldy, and prone to name changes and misspellings, so I would personally have used invented keys in many places, although I'd have to pay for it with some added ceremony in my SQL statements.

So, it is perfectly fine for you to use natural keys in your database, but it also makes sense to use invented keys – it's your call!

API

The API below is what we want you to implement, please be very careful to use exactly the same names as below in your API, and return exactly the same status codes as specified, otherwise the test script will not accept your solution. The indentation of the JSON objects you return doesn't matter, as long as it is valid JSON.

You can check your solution by saving the test script as a text file (in any directory), and then run it using

$ python check-krusty.py

The test requires that your service runs at port 8888 on localhost – if you use Windows, and your service is very slow to respond when you use curl calls to localhost, you can substitute localhost with 127.0.0.1 (which is the address localhost points to) to get a dramatic speed increase. The test script uses 127.0.0.1 for this reason.

Reset the database

A call to:

POST /reset

should remove all customers, recepies, ingredients, orders, and pallets from the database – this should never fail.

It returns:

  • status: 205 (which is a recommendation for clients to update their view of the resource)
  • body:

    { "location": "/" }
    

    This is a bit contrived, but after posts, we often want a link to the newly posted resource – in this case it's really the whole database, so we might as well return the 'root' path (/). Also, as noted above, REST services normally return this link in the response header, but to keep the project simple, we return it in the response body.

Add and check customers

To add a new customer, we post to the /customers endpoint:

POST /customers

with the body:

{
    "name": "Bullar och bong",
    "address": "Bakgatan 4, Lund"
}

(see notes at the bottom of this page for information about how to send a JSON body when testing with curl).

This should give the following response:

  • status: 201 (we've added a new resource)
  • body:

    { "location": "/customers/Bullar%20och%20bong" }
    

    This is sent in the body of the response, so we could have returned the name 'Bullar och bong', but since it's supposed to be a link to the created resource, which will later be sent as a part of a URL, we want it URL encoded anyway.

To make things easier, we can assume that the client doesn't try to create a new customer with the same name as an old customer.

To see all customers, we use:

GET /customers

and we'll get:

  • status: 200
  • body:

{
    "data": [
        {
            "name": "Bullar och bong",
            "address": "Bakgatan 4, Lund"
        },
        {
            "name": "Café Ingalunda",
            "address": "Återvändsgränden 1, Kivik"
        },
        {
            "name": "Kakbak HB",
            "address": "Degkroken 8, Malmö"
        }
    ]
}

The names of the fields (name and address) must be exactly the same as above, but the indentation is free as long as it is valid JSON.

Add and check ingredients

To inform the system of an ingredient we want to use in our recipes, we use:

POST /ingredients

and the body:

{
    "ingredient": "Bread crumbs",
    "unit": "g"
}

which returns:

  • status: 201
  • body:

    {"location": "/ingredients/Bread%20crumbs"}
    

Some ingredients will need URL encoding, so we use it for all ingredients – you can safely assume that no one tries to add an ingredient which is already in the database.

To update our current stock of an ingredient (after a delivery), we use:

POST /ingredients/Bread%20crumbs/deliveries

with the body

{
    "deliveryTime": "2024-03-05 10:30:00",
    "quantity" : 20000
}

which increases our stock of "Bread crumbs" with 20000 units (i.e., in this case grams).

An update should return:

  • status: 201
  • body:

    {
        "data": {
            "ingredient": "Bread crumbs",
            "quantity": 36000,
            "unit": "g"
        },
    }
    

    where "quantity" is the total quantity after the delivery.

To see all registered ingredients, and their current stock, we call:

GET /ingredients

It returns:

  • status: 200
  • body:

    {
        "data": [
            {
                "ingredient": "Chocolate",
                "quantity": 42000,
                "unit": "g"
            },
            {
                "ingredient": "Bread crumbs",
                "quantity": 36000,
                "unit": "g"
            },
        ]
    }
    

Add and check recipes/cookies

To add a cookie, we post to /cookies:

POST /cookies

and add the name and repice in the request body:

{
    "name": "Almond delight",
    "recipe": [
        {
            "ingredient": "Butter",
            "amount": 400
        },
        {
            "ingredient": "Sugar",
            "amount": 270
        },
        {
            "ingredient": "Chopped almonds",
            "amount": 279
        },
        {
            "ingredient": "Flour",
            "amount": 400
        },
        {
            "ingredient": "Cinnamon",
            "amount": 10
        }
    ]
}

The units for the ingredients are given when we define them using POST to /ingredients, so we don't need to give them here.

As above, we can assume that no one tries to add the same cookie several times (i.e., you don't have to check for name collisions) – and our test script will only use ingredients which have been added properly beforehand, so it's safe to assume that all ingredients are OK (but all bets are off when you test it yourself…).

The return values will be:

  • status: 201 (since we assume it's a new cookie)
  • body:

    {"location": "/cookies/Almond%20delight"}
    

We can list all our cookies with:

GET /cookies

which returns:

  • status: 200
  • body:

    {
        "data": [
            {
                "name": "Almond delight"
            },
            {
                "name": "Amneris"
            },
            {
                "name": "Berliner"
            },
            ...
        ]
    }
    

    This endpoint will be slightly modified below (see "Cookies, part II") – but for now, the output above suffices.

To see a specific recipe, we use the

GET /cookies/<cookie_name>/recipe

endpoint, so to see the recipe for "Almond delight", we send:

GET /cookies/Almond%20delight/recipe

It returns:

  • status: 200 if the cookie exists, or 404 if there is no such cookie
  • body: if the cookie exists, we return:

    {
        "data": [
            {
                "ingredient": "Butter",
                "amount": 400,
                "unit": "g"
            },
            {
                "ingredient": "Sugar",
                "amount": 270,
                "unit": "g"
            },
            {
                "ingredient": "Chopped almonds",
                "amount": 279,
                "unit": "g"
            },
            {
                "ingredient": "Flour",
                "amount": 400,
                "unit": "g"
            },
            {
                "ingredient": "Cinnamon",
                "amount": 10,
                "unit": "g"
            }
        ]
    }
    

    If there is no such cookie, we return an empty recipe:

    {
        "data": []
    }
    

Add and check pallets

When a pallet is about to be produced, we call:

POST /pallets

with the request body

{
    "cookie": "Tango"
}

This puts the new pallet in the store immediately (so the time stamp should be 'now'), but only if there are ingredients enough to bake the whole pallet. This should be checked by the database itself, using constraints and/or triggers, we don't want your client program (i.e., your Python or Java code) to handle ingredients.

The call returns:

  • status: 201 if there are ingredients enough for one pallet, otherwise we return 422.
  • body: if a pallet is produced, we want its 'location', such as:

    {"location": "/pallets/e02b15b7b3e4ac432669c6b0fa294692"}
    

    If no pallet is produced, we return

    {"location": ""}
    

To check all our pallets we call:

GET /pallets

with the following query parameters (we can use any combination of them, or none):

  • cookie: the name of the cookie
  • after: only show pallets produced after (not including) the given date
  • before: only show pallets produced before (not including) the given date

So, to check all pallets with "Almond delight", baked before "2024-03-02", we run:

GET /pallets\?cookie=Almond%20delight\&before=2024-03-02

and get:

  • status: 200
  • body:

    {
        "data": [
            {
                "id": "e515eb06a8cb417256f7c8736fa8bec4",
                "cookie": "Almond delight",
                "productionDate": "2024-03-01",
                "blocked": 0
            },
            {
                "id": "c03b044998a9092bc44527fcd65d5cf1",
                "cookie": "Almond delight",
                "productionDate": "2024-02-28",
                "blocked": 0
            },
            ...
        ]
    }
    

    Note that we only want pallets which are still in our store (but in this version of our service, we have no way to mark a pallet for delivery – see the section "Beyond this project" below if you want to try it out). We'll come to the "blocked" field below, but when a pallet is blocked, it has the value of blocked set to 1, otherwise it is 0.

Blocking and unblocking

To block or unblock pallets with a given cookie, we use

POST /cookies/<cookie_name>/block

or

POST /cookies/<cookie_name>/unblock

with the potential query parameters (we can use any combination of them, or none):

  • after: only block pallets baked after (not including) a given date
  • before: only block pallets baked before (not including) a given date

So, to block all pallets of "Tango" baked in the last week of February, we call:

POST /cookies/Tango/block\?after=2024-02-21\&before=2024-03-01

and to unblock all pallets of "Almond delight" we call:

POST /cookies/Almond%20delight/unblock

The call should return

  • status: 205 (clients may want to update their views)
  • body: an empty string

Cookies, part II

Above, we wanted the call:

GET /cookies

to return only the names of the cookies, but now that we've introduced endpoints for pallets, we want the output of GET on /cookies to show the number of unblocked pallets we have with the cookie in store:

  • status: 200
  • body:

    {
        "data": [
            {
                "name": "Almond delight",
                "pallets": 2
            },
            {
                "name": "Amneris",
                "pallets": 0
            },
            {
                "name": "Berliner",
                "pallets": 10
            },
            ...
        ]
    }
    

This means that you must modify the code which handled GET on /cookies, you should not add a new endpoint for this!

Observe that we have 0 pallets of "Amneris" in the example above – all cookies should be present here, even if we have no unblocked pallets of them in store!

Beyond this project (optional)

There are some quite obvious endpoints missing above, but you don't need to implement them to pass the project. In case you want some more practice, you can try the following:

Orders

We create a new order using:

POST /orders

with the body:

{
    "customer": "Bullar och bong",
    "deliveryDate": "2024-03-16",
    "cookies": [
        {
            "cookie": "Almond delight",
            "count": 3
        },
        {
            "cookie": "Tango",
            "count": 1
        }
    ]
}

It returns:

  • status: 404 if either the company or any of the cookies doesn't exist, otherwise 201
  • body: if the order is correct (201 above), we return:

    {"location": "/orders/f0430cff188d6a340a7df2557898b561"}
    

    Here, "f0430cff188d6a340a7df2557898b561" is the order-id.

    I anything is wrong with the order (i.e., we return the status code 404), we return an empty string.

Other endpoints

You can then try to come up with endpoints for:

  • Showing the orders of a given customer (probably under /customers/...)
  • Checking how many pallets of each cookie we must bake before a given date (given how many orders we have for it, and how many pallets with it are already in store)

Logging the SQL statements

The sqlite3 package has a clever feature allowing us to log the SQL code executed over a connection – if we add a call to .set_trace_callback(print) on our connection:

db = sqlite3.connect("krusty.sqlite")
db.set_trace_callback(print)

all SQL-code run inside execute statements will be printed.

Testing with curl

You can test your solution by downloading and running a test script – just download it as a text file, and run it with:

$ python check-krusty.py

If you want to test 'manually' during development, you can either use curl at the command line, or a tool like insomnia~/~postman. For sending JSON objects in the request body, using insomnia~/~postman is probably easier, but you can do it using curl using the -d and -H flags – please observe the -H flag at the far right of the line:

$ curl -X POST http://localhost:7007/movies -d '{"imdbKey": "tt5580390","title": "The Shape of Water","year": 2017}' -H "Content-Type: application/json"

(and, as said above, if you use Windows, and the call above is very slow, replace localhost with 127.0.0.1).