This project was part of a 35-hour challenge, along with a corresponding WP plugin, designed to see what I can do in a small amount of time. This portion required that I use Symfony Framework and Doctrine; two things that I've no previous experience of.
What we have here is a simple API for CRUD operations, intending to be used for creating, retrieving, updating and deleting famous quotes and their authors.
Authors (is that the right term?) can be created without having any quotes. In fact, it's required that you create an author before you can record any quotes for them. Quotes cannot have no author.
This code should exist on Github at Antnee/famousquotesapi, and the accompanying WordPress plugin at Antnee/famousquoteswp. I rarely leave any code publicly available, so don't expect these repositories to exist forever.
All IDs are UUID v4 and generated by the Ramsey/UUID
library. I did not use auto-incrementing integer values because they're just
really bad practice. UUIDs can have major performance issues when used as the
primary key in MySQL, and if I expected this API to get serious usage I would
have implemented ordered UUIDs.
Likewise, I should really have made the ID fields binary
or int
, but as I
wasn't too sure about how Doctrine would handle it all, I decided to simplify
and use a string type (varchar
in MySQL). In production, this is bad. Don't
do this.
All success messages will be returned in the following format:
{
"requestId": "12345678-90ab-cdef-g123-4567890abcde",
"time": 1234567890,
"error": null,
"content": {
"foo": "bar"
}
}
The content may be an array where more than one item will be returned, an object where there is a single item to return, or a boolean value in the case of a delete operation.
Note that success does not mean a 200
HTTP status code every time. Where a
new resource is created you will receive a 201
instead.
Where there is an actual error, the response will look like this:
{
"requestId": "12345678-90ab-cdef-g123-4567890abcde",
"time": 1234567890,
"error": {
"code": 1234,
"message": "Error Message"
},
"content": null
}
Errors may come as 400
, 401
and 404
HTTP status codes. You should never
receive a 403
as once you're authorised, you can do anything. No roles or
permissions here.
You should never find that both error
and content
are both null
. Nor
should it be possible for them to both not be null
at the same time. If this
happens then you've found a bug.
Some simple authentication is included. Namely, you must provide an x-api-key
header with every request, regardless of endpoint and method. If you are
authenticated then you can perform any actions.
The API keys are currently hard-coded into /src/Security/ApiKeyUserProvider.php
and the bundled key is simply fhRBi4atT9xcLlQBJMz7lRDH1HL480shLdYlfmuPulQ
.
You can add more if you like, and you can remove that one, but that's how it's
bundled for now. DB/Doctrine is used for the Author
and Quote
entities; I
didn't feel it necessary to use a DB connection for this. However, it would be
fairly trivial to add a DB based provider if required.
Here follows a list of available endpoints, simple examples of how to use them, and what the response should look like.
Redirects to /ping
GET http://localhost/
{
"requestId": "344255f8-4832-4574-8315-eae5fb147151",
"time": 1234567890,
"error": null,
"content": {
"ack": 1234567890
}
}
Redirects to /quotes/random
Returns a random quote
GET http://localhost/quotes/random
{
"requestId": "1e440c1b-0801-4f27-9a06-b10a8abea174",
"time": 1234567890,
"error": null,
"content": {
"id": "51f74f6e-8e20-42ec-ba21-ac3ae62658ef",
"text": "If at first you don't succeed, try, try again",
"author": {
"id": "51f74f6e-8e20-42ec-ba21-ac3ae62658ef",
"name": "William Edward Hickson",
"quotes": 1
}
}
}
Returns a specific quote
GET http://localhost/quotes/51f74f6e-8e20-42ec-ba21-ac3ae62658ef
{
"requestId": "1e440c1b-0801-4f27-9a06-b10a8abea174",
"time": 1234567890,
"error": null,
"content": {
"id": "51f74f6e-8e20-42ec-ba21-ac3ae62658ef",
"text": "If at first you don't succeed, try, try again",
"author": {
"id": "51f74f6e-8e20-42ec-ba21-ac3ae62658ef",
"name": "William Edward Hickson",
"quotes": 1
}
}
}
Deletes a given quote ID
DELETE http://localhost/quotes/51f74f6e-8e20-42ec-ba21-ac3ae62658ef
{
"requestId": "8861da06-e2ac-4fdc-b521-81f6a8d72836",
"time": 1234567890,
"error": null,
"content": true
}
Notice the true
boolean value in content indicating success
Updates the quote text, the author ID, or both (making this similar to a PUT
)
PATCH http://localhost/quotes/ea95b2d1-504f-40e6-8736-36b0b752dca1
{
"text": "It's one small step for man, one giant leap for mankind",
"authorId": "b16d3b31-3bda-49a4-a661-50280a0c50ca"
}
{
"requestId": "ff782c32-46d7-403e-abd8-d578a5fdb087",
"time": 1234567890,
"error": null,
"content": {
"id": "75c07c54-706a-4c4e-9a10-3435dcf035bb",
"text": "It's one small step for man, one giant leap for mankind",
"author": {
"id": "b16d3b31-3bda-49a4-a661-50280a0c50ca",
"name": "Neil Armstrong",
"quotes": 1
}
}
}
Similar to PATCH /quotes/{id}
, except you must pass a full, valid quote
object (excluding the ID, which is obtained from the URI), which will
completely replace the quote at this ID
Returns all authors
GET http://localhost/authors
{
"requestId": "fb8fb9e6-83fd-4b22-bc7c-8fb91036bed7",
"time": 1234567890,
"error": null,
"content": [
[
{
"id": "31d14cc5-93c7-4f37-bd54-0b987b0a9acf",
"name": "Robert Kennedy",
"quotes": 3
},
{
"id": "32e98bce-472c-4b1c-989c-7911e04a17ed",
"name": "will.i.am",
"quotes": 1
},
{
"id": "51f74f6e-8e20-42ec-ba21-ac3ae62658ef",
"name": "William Edward Hickson",
"quotes": 1
},
{
"id": "87e7106b-2d2b-4a5d-9a72-882e2c7f8acb",
"name": "Johny Five",
"quotes": 1
},
{
"id": "b16d3b31-3bda-49a4-a661-50280a0c50ca",
"name": "Neil Armstrong",
"quotes": 1
}
]
]
}
Create a new author
POST http://localhost/authors
{
"name": "Neil Armstrong"
}
{
"requestId": "8e201ea7-f0ac-4bf5-8c17-e22191cd0086",
"time": 1234567890,
"error": null,
"content": {
"id": "b16d3b31-3bda-49a4-a661-50280a0c50ca",
"name": "Neil Armstrong",
"quotes": 0
}
}
Returns details about the named author
GET http://localhost/authors/william%20edward%20hickson
{
"requestId": "3484a639-0eed-4185-b4c2-b9e69825eb77",
"time": 1234567890,
"error": null,
"content": [
{
"id": "51f74f6e-8e20-42ec-ba21-ac3ae62658ef",
"name": "William Edward Hickson",
"quotes": 1
}
]
}
Deletes an author, and their corresponding quotes
DELETE http://localhost/authors/neil%20armstrong
{
"requestId": "0505ed2b-8cad-44c1-ac3e-560ea8370c0b",
"time": 1234567890,
"error": null,
"content": true
}
Notice the true
boolean value in content indicating success
Patches the author's name. Nothing else can be patched. Consequently, a PATCH
and a PUT
are identical at this endpoint
PATCH http://localhost/authors/neil%20armstrong
{
"name": "Buzz Aldrin"
}
{
"requestId": "2fa9d45e-4896-4e65-867a-387faaa03e8b",
"time": 1234567890,
"error": null,
"content": {
"id": "860970db-621f-402b-b286-0861b7bd5dfd",
"name": "Buzz Aldrin",
"quotes": 0
}
}
Note that if we now GET /authors/buzz%20aldrin/quotes
we now see this famous
quote as attributed to Buzz, instead:
{
"requestId": "50cc8fe3-7d53-4ce5-b615-104a18071978",
"time": 123456789,
"error": null,
"content": [
{
"id": "468dcbe9-369c-4605-b62f-485f1d122159",
"text": "It's one small step for man, one giant leap for mankind",
"author": {
"id": "53801e48-bfdd-46e0-a982-a888a5e90243",
"name": "Buzz Aldrin",
"quotes": 1
}
}
]
}
See PATCH /authors/{name}
Gets all quotes by this author
GET http://localhost/authors/william%20edward%20hickson/quotes
{
"requestId": "cb7fdd33-db4d-4169-a724-e30b4424ff25",
"time": 1234567890,
"error": null,
"content": [
{
"id": "51f74f6e-8e20-42ec-ba21-ac3ae62658ef",
"text": "If at first you don't succeed, try, try again",
"author": {
"id": "51f74f6e-8e20-42ec-ba21-ac3ae62658ef",
"name": "William Edward Hickson",
"quotes": 1
}
}
]
}
The following Exceptions exist in the application. You may not see all of these in your responses, but they're documented here for completion:
Code | Exception | Explanation |
---|---|---|
1001 | NoAuthorsFoundException | No authors exist in the database at all |
1002 | AuthorIdNotFoundException | An author ID was provided, but it did not match a known author |
1003 | AuthorNameNotFoundException | Am author name was provided, but it did not match a known author |
1011 | AuthorDataInvalidException | The author data provided did not match the required specification |
1102 | AuthorNotAddedException | The author that you tried to insert could not be added |
1103 | AuthorNotUpdatedException | The author record could not be updated |
1104 | AuthorNotDeletedException | The author record could not be removed |
1201 | AuthorQuoteCountNotZeroException | The author has more than zero quotes. Can occur during an author delete scenario |
2001 | NoQuotesFoundException | No quotes exist in the database at all |
2011 | QuoteDataInvalidException | The quote data provided did not match the required specification |
2102 | QuoteNotAddedException | The quote that you tried to insert could not be added |
9001 | InvalidApiKeyException | The provided API key was not recognised |
- If you request quotes for an unknown author (eg
GET /authors/doesnotexist/quotes
) you will receive an empty array in response, and not receive a1003
unrecognised author error DELETE
operations return a boolean value, rather than an error. I'm tempted to go back and do this properly, but that would defeat the purpose of this challenge. Just be aware that I know it's inconsistent and not a great design- You cannot
GET
at/authors/{name}/quotes/{id}
. Nor can youDELETE
,PATCH
orPUT
. After you've sent aPOST
to/authors/{name}/quotes
you must retrieve the quote by performing aGET
on/quotes/{id}
. With more time I would probably allow these methods at these endpoints as it seems somewhat obvious and a sort of filter (ie only get this quote ID if this is the author's name), but it's far from necessary in this timeframe - There is no pagination. I don't expect that there'll be enough data in here to justify the extra logic at both ends. For a larger solution it would be necessary
- There is no validation, either. The closest that we get to this is that Doctrine won't allow invalid data to be inserted into the database. But other than that there is nothing. This clearly will not wash in a production system
- The authentication system works fine when you give correct credentials, however there is presently no handling for invalid (or missing) credentials, so you will see a default 500 exception from Symfony. This shouldn't be too difficult to resolve, but not having any experience oif Symfony at all prior to a few days ago, I elected to leave it for now. I am aware that it's a problem, however
- I haven't implemented HATEOAS or similar. This is not a model API. There are a few architectural things that I'm not pleased about. Symfony may have the answer already, or I could optimise my code, but that's not the point of a) this challenge or b) creating a prototype
- Each quote is available to all API callers. There is no pool per user, so if one API consumer deleted a quote, it would be replicated across all consumers. The theory here is that this is your API, and you are the consumer
Where are the tests, Anthony? I would usually write tests first, then build my application around these tests. However, because I needed to learn both Symfony and Doctrine as I went, I needed to build first, test later. I made the judgement that I should skip tests. I was very thorough in my previous challenge so I didn't feel that I needed to prove anything while learning two new major elements at the same time.
I have however included a Postman collection dump for the Quotes and Author
collections that I've been using for my testing in /tests/postman
which can
be imported for manual testing. It's not the same thing, at all, but it helps
understand and to aid integration.