diff --git a/Conduit.postman_collection.json b/Conduit.postman_collection.json new file mode 100644 index 0000000..c387b73 --- /dev/null +++ b/Conduit.postman_collection.json @@ -0,0 +1,2246 @@ +{ + "info":{ + "_postman_id":"0574ad8a-a525-43ae-8e1e-5fd9756037f4", + "name":"Conduit", + "description":"Collection for testing the Conduit API\n\nhttps://github.com/gothinkster/realworld", + "schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item":[ + { + "name":"Auth", + "item":[ + { + "name":"Register", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "if (!(environment.isIntegrationTest)) {", + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", + "", + "var user = responseJSON.user || {};", + "", + "tests['User has \"email\" property'] = user.hasOwnProperty('email');", + "tests['User has \"username\" property'] = user.hasOwnProperty('username');", + "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", + "tests['User has \"image\" property'] = user.hasOwnProperty('image');", + "tests['User has \"token\" property'] = user.hasOwnProperty('token');", + "}", + "" + ] + } + } + ], + "request":{ + "method":"POST", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + } + ], + "body":{ + "mode":"raw", + "raw":"{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\", \"username\":\"{{USERNAME}}\"}}" + }, + "url":{ + "raw":"{{APIURL}}/users", + "host":[ + "{{APIURL}}" + ], + "path":[ + "users" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Login", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", + "", + "var user = responseJSON.user || {};", + "", + "tests['User has \"email\" property'] = user.hasOwnProperty('email');", + "tests['User has \"username\" property'] = user.hasOwnProperty('username');", + "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", + "tests['User has \"image\" property'] = user.hasOwnProperty('image');", + "tests['User has \"token\" property'] = user.hasOwnProperty('token');", + "" + ] + } + } + ], + "request":{ + "method":"POST", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + } + ], + "body":{ + "mode":"raw", + "raw":"{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}" + }, + "url":{ + "raw":"{{APIURL}}/users/login", + "host":[ + "{{APIURL}}" + ], + "path":[ + "users", + "login" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Login and Remember Token", + "event":[ + { + "listen":"test", + "script":{ + "id":"a7674032-bf09-4ae7-8224-4afa2fb1a9f9", + "type":"text/javascript", + "exec":[ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", + "", + "var user = responseJSON.user || {};", + "", + "tests['User has \"email\" property'] = user.hasOwnProperty('email');", + "tests['User has \"username\" property'] = user.hasOwnProperty('username');", + "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", + "tests['User has \"image\" property'] = user.hasOwnProperty('image');", + "tests['User has \"token\" property'] = user.hasOwnProperty('token');", + "", + "if(tests['User has \"token\" property']){", + " pm.globals.set('token', user.token);", + "}", + "", + "tests['Global variable \"token\" has been set'] = pm.globals.get('token') === user.token;", + "" + ] + } + } + ], + "request":{ + "method":"POST", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + } + ], + "body":{ + "mode":"raw", + "raw":"{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}" + }, + "url":{ + "raw":"{{APIURL}}/users/login", + "host":[ + "{{APIURL}}" + ], + "path":[ + "users", + "login" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Current User", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", + "", + "var user = responseJSON.user || {};", + "", + "tests['User has \"email\" property'] = user.hasOwnProperty('email');", + "tests['User has \"username\" property'] = user.hasOwnProperty('username');", + "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", + "tests['User has \"image\" property'] = user.hasOwnProperty('image');", + "tests['User has \"token\" property'] = user.hasOwnProperty('token');", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/user", + "host":[ + "{{APIURL}}" + ], + "path":[ + "user" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Update User", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", + "", + "var user = responseJSON.user || {};", + "", + "tests['User has \"email\" property'] = user.hasOwnProperty('email');", + "tests['User has \"username\" property'] = user.hasOwnProperty('username');", + "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", + "tests['User has \"image\" property'] = user.hasOwnProperty('image');", + "tests['User has \"token\" property'] = user.hasOwnProperty('token');", + "" + ] + } + } + ], + "request":{ + "method":"PUT", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"{\"user\":{\"email\":\"{{EMAIL}}\"}}" + }, + "url":{ + "raw":"{{APIURL}}/user", + "host":[ + "{{APIURL}}" + ], + "path":[ + "user" + ] + } + }, + "response":[ + + ] + } + ] + }, + { + "name":"Articles", + "item":[ + { + "name":"All Articles", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + "", + " if(responseJSON.articles.length){", + " var article = responseJSON.articles[0];", + "", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + " } else {", + " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", + " }", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Articles by Author", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + "", + " if(responseJSON.articles.length){", + " var article = responseJSON.articles[0];", + "", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + " } else {", + " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", + " }", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles?author=johnjacob", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ], + "query":[ + { + "key":"author", + "value":"johnjacob" + } + ] + } + }, + "response":[ + + ] + }, + { + "name":"Articles Favorited by Username", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + " ", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + "", + " if(responseJSON.articles.length){", + " var article = responseJSON.articles[0];", + "", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + " } else {", + " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", + " }", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles?favorited={{USERNAME}}", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ], + "query":[ + { + "key":"favorited", + "value":"{{USERNAME}}" + } + ] + } + }, + "response":[ + + ] + }, + { + "name":"Articles by Tag", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + "", + " if(responseJSON.articles.length){", + " var article = responseJSON.articles[0];", + "", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + " } else {", + " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", + " }", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles?tag=dragons", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ], + "query":[ + { + "key":"tag", + "value":"dragons" + } + ] + } + }, + "response":[ + + ] + } + ] + }, + { + "name":"Articles, Favorite, Comments", + "item":[ + { + "name":"Create Article", + "event":[ + { + "listen":"test", + "script":{ + "id":"e711dbf8-8065-4ba8-8b74-f1639a7d8208", + "type":"text/javascript", + "exec":[ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", + "", + "var article = responseJSON.article || {};", + "", + "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + "pm.globals.set('slug', article.slug);", + "", + "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + "" + ] + } + } + ], + "request":{ + "method":"POST", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"{\"article\":{\"title\":\"How to train your dragon\", \"description\":\"Ever wonder how?\", \"body\":\"Very carefully.\", \"tagList\":[\"training\", \"dragons\"]}}" + }, + "url":{ + "raw":"{{APIURL}}/articles", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Feed", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + "", + " if(responseJSON.articles.length){", + " var article = responseJSON.articles[0];", + "", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + " } else {", + " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", + " }", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles/feed", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles", + "feed" + ] + } + }, + "response":[ + + ] + }, + { + "name":"All Articles", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + "", + " if(responseJSON.articles.length){", + " var article = responseJSON.articles[0];", + "", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + " } else {", + " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", + " }", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ] + } + }, + "response":[ + + ] + }, + { + "name":"All Articles with auth", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + "", + " if(responseJSON.articles.length){", + " var article = responseJSON.articles[0];", + "", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + " } else {", + " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", + " }", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Articles by Author", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + "", + " if(responseJSON.articles.length){", + " var article = responseJSON.articles[0];", + "", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + " } else {", + " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", + " }", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles?author={{USERNAME}}", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ], + "query":[ + { + "key":"author", + "value":"{{USERNAME}}" + } + ] + } + }, + "response":[ + + ] + }, + { + "name":"Articles by Author with auth", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + "", + " if(responseJSON.articles.length){", + " var article = responseJSON.articles[0];", + "", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + " } else {", + " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;", + " }", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles?author={{USERNAME}}", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ], + "query":[ + { + "key":"author", + "value":"{{USERNAME}}" + } + ] + } + }, + "response":[ + + ] + }, + { + "name":"Single Article by slug", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", + "", + "var article = responseJSON.article || {};", + "", + "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles/{{slug}}", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles", + "{{slug}}" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Articles by Tag", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + " var article = responseJSON.articles[0];", + " tests['An article was returned'] = article !== undefined;", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['The first tag is dragons'] = article.tagList[0] === 'dragons';", + " tests['The second tag is training'] = article.tagList[1] === 'training';", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles?tag=dragons", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ], + "query":[ + { + "key":"tag", + "value":"dragons" + } + ] + } + }, + "response":[ + + ] + }, + { + "name":"Update Article", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "if (!(environment.isIntegrationTest)) {", + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", + "", + "var article = responseJSON.article || {};", + "", + "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + "}", + "" + ] + } + } + ], + "request":{ + "method":"PUT", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"{\"article\":{\"body\":\"With two hands\"}}" + }, + "url":{ + "raw":"{{APIURL}}/articles/{{slug}}", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles", + "{{slug}}" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Favorite Article", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", + "", + "var article = responseJSON.article || {};", + "", + "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + "tests[\"Article's 'favorited' property is true\"] = article.favorited === true;", + "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + "tests[\"Article's 'favoritesCount' property is greater than 0\"] = article.favoritesCount > 0;", + "" + ] + } + } + ], + "request":{ + "method":"POST", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles/{{slug}}/favorite", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles", + "{{slug}}", + "favorite" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Articles Favorited by Username", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + " article = responseJSON.articles[0];", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is 1'] = article.favoritesCount === 1;", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles?favorited={{USERNAME}}", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ], + "query":[ + { + "key":"favorited", + "value":"{{USERNAME}}" + } + ] + } + }, + "response":[ + + ] + }, + { + "name":"Articles Favorited by Username with auth", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + " article = responseJSON.articles[0];", + " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');", + " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');", + " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);", + " tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + " tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + " tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + " tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is 1'] = article.favoritesCount === 1;", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles?favorited={{USERNAME}}", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles" + ], + "query":[ + { + "key":"favorited", + "value":"{{USERNAME}}" + } + ] + } + }, + "response":[ + + ] + }, + { + "name":"Unfavorite Article", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", + "", + "var article = responseJSON.article || {};", + "", + "tests['Article has \"title\" property'] = article.hasOwnProperty('title');", + "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');", + "tests['Article has \"body\" property'] = article.hasOwnProperty('body');", + "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');", + "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.createdAt);", + "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');", + "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(article.updatedAt);", + "tests['Article has \"description\" property'] = article.hasOwnProperty('description');", + "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');", + "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);", + "tests['Article has \"author\" property'] = article.hasOwnProperty('author');", + "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');", + "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');", + "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);", + "tests[\"Article's \\\"favorited\\\" property is false\"] = article.favorited === false;", + "" + ] + } + } + ], + "request":{ + "method":"DELETE", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles/{{slug}}/favorite", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles", + "{{slug}}", + "favorite" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Create Comment for Article", + "event":[ + { + "listen":"test", + "script":{ + "id":"9f90c364-cc68-4728-961a-85eb00197d7b", + "type":"text/javascript", + "exec":[ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"comment\" property'] = responseJSON.hasOwnProperty('comment');", + "", + "var comment = responseJSON.comment || {};", + "", + "tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');", + "pm.globals.set('commentId', comment.id);", + "", + "tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');", + "tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');", + "tests['\"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.createdAt);", + "tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');", + "tests['\"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.updatedAt);", + "tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');", + "" + ] + } + } + ], + "request":{ + "method":"POST", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"{\"comment\":{\"body\":\"Thank you so much!\"}}" + }, + "url":{ + "raw":"{{APIURL}}/articles/{{slug}}/comments", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles", + "{{slug}}", + "comments" + ] + } + }, + "response":[ + + ] + }, + { + "name":"All Comments for Article", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"comments\" property'] = responseJSON.hasOwnProperty('comments');", + "", + " if(responseJSON.comments.length){", + " var comment = responseJSON.comments[0];", + "", + " tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');", + " tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');", + " tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');", + " tests['\"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.createdAt);", + " tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');", + " tests['\"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.updatedAt);", + " tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');", + " }", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles/{{slug}}/comments", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles", + "{{slug}}", + "comments" + ] + } + }, + "response":[ + + ] + }, + { + "name":"All Comments for Article without login", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"comments\" property'] = responseJSON.hasOwnProperty('comments');", + "", + " if(responseJSON.comments.length){", + " var comment = responseJSON.comments[0];", + "", + " tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');", + " tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');", + " tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');", + " tests['\"createdAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.createdAt);", + " tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');", + " tests['\"updatedAt\" property is an ISO 8601 timestamp'] = /^\\d{4,}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)$/.test(comment.updatedAt);", + " tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');", + " }", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles/{{slug}}/comments", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles", + "{{slug}}", + "comments" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Delete Comment for Article", + "request":{ + "method":"DELETE", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles/{{slug}}/comments/{{commentId}}", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles", + "{{slug}}", + "comments", + "{{commentId}}" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Delete Article", + "request":{ + "method":"DELETE", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/articles/{{slug}}", + "host":[ + "{{APIURL}}" + ], + "path":[ + "articles", + "{{slug}}" + ] + } + }, + "response":[ + + ] + } + ], + "event":[ + { + "listen":"prerequest", + "script":{ + "id":"67853a4a-e972-4573-a295-dad12a46a9d7", + "type":"text/javascript", + "exec":[ + "" + ] + } + }, + { + "listen":"test", + "script":{ + "id":"3057f989-15e4-484e-b8fa-a041043d0ac0", + "type":"text/javascript", + "exec":[ + "" + ] + } + } + ] + }, + { + "name":"Profiles", + "item":[ + { + "name":"Register Celeb", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "if (!(environment.isIntegrationTest)) {", + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", + "", + "var user = responseJSON.user || {};", + "", + "tests['User has \"email\" property'] = user.hasOwnProperty('email');", + "tests['User has \"username\" property'] = user.hasOwnProperty('username');", + "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');", + "tests['User has \"image\" property'] = user.hasOwnProperty('image');", + "tests['User has \"token\" property'] = user.hasOwnProperty('token');", + "}", + "" + ] + } + } + ], + "request":{ + "method":"POST", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + } + ], + "body":{ + "mode":"raw", + "raw":"{\"user\":{\"email\":\"celeb_{{EMAIL}}\", \"password\":\"{{PASSWORD}}\", \"username\":\"celeb_{{USERNAME}}\"}}" + }, + "url":{ + "raw":"{{APIURL}}/users", + "host":[ + "{{APIURL}}" + ], + "path":[ + "users" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Profile", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "if (!(environment.isIntegrationTest)) {", + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", + " ", + " var profile = responseJSON.profile || {};", + " ", + " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", + " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');", + " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", + " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", + "}", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/profiles/celeb_{{USERNAME}}", + "host":[ + "{{APIURL}}" + ], + "path":[ + "profiles", + "celeb_{{USERNAME}}" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Follow Profile", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "if (!(environment.isIntegrationTest)) {", + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", + " ", + " var profile = responseJSON.profile || {};", + " ", + " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", + " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');", + " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", + " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", + " tests['Profile\\'s \"following\" property is true'] = profile.following === true;", + "}", + "}", + "" + ] + } + } + ], + "request":{ + "method":"POST", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"{\"user\":{\"email\":\"{{EMAIL}}\"}}" + }, + "url":{ + "raw":"{{APIURL}}/profiles/celeb_{{USERNAME}}/follow", + "host":[ + "{{APIURL}}" + ], + "path":[ + "profiles", + "celeb_{{USERNAME}}", + "follow" + ] + } + }, + "response":[ + + ] + }, + { + "name":"Unfollow Profile", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "if (!(environment.isIntegrationTest)) {", + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", + " ", + " var profile = responseJSON.profile || {};", + " ", + " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", + " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');", + " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", + " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", + " tests['Profile\\'s \"following\" property is false'] = profile.following === false;", + "}", + "}", + "" + ] + } + } + ], + "request":{ + "method":"DELETE", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + }, + { + "key":"Authorization", + "value":"Token {{token}}" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/profiles/celeb_{{USERNAME}}/follow", + "host":[ + "{{APIURL}}" + ], + "path":[ + "profiles", + "celeb_{{USERNAME}}", + "follow" + ] + } + }, + "response":[ + + ] + } + ] + }, + { + "name":"Tags", + "item":[ + { + "name":"All Tags", + "event":[ + { + "listen":"test", + "script":{ + "type":"text/javascript", + "exec":[ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + " ", + " tests['Response contains \"tags\" property'] = responseJSON.hasOwnProperty('tags');", + " tests['\"tags\" property returned as array'] = Array.isArray(responseJSON.tags);", + "}", + "" + ] + } + } + ], + "request":{ + "method":"GET", + "header":[ + { + "key":"Content-Type", + "value":"application/json" + }, + { + "key":"X-Requested-With", + "value":"XMLHttpRequest" + } + ], + "body":{ + "mode":"raw", + "raw":"" + }, + "url":{ + "raw":"{{APIURL}}/tags", + "host":[ + "{{APIURL}}" + ], + "path":[ + "tags" + ] + } + }, + "response":[ + + ] + } + ] + } + ] +} diff --git a/app/Main.hs b/app/Main.hs deleted file mode 100644 index 6a96e13..0000000 --- a/app/Main.hs +++ /dev/null @@ -1,4 +0,0 @@ -module Main where - -main :: IO () -main = print "Okapi" diff --git a/examples/calc/Main.hs b/examples/calc/Main.hs new file mode 100644 index 0000000..41ca1e5 --- /dev/null +++ b/examples/calc/Main.hs @@ -0,0 +1,71 @@ +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} + +module Main where + +import Control.Applicative ((<|>)) +import Data.Aeson (ToJSON) +import Data.Text +import GHC.Generics (Generic) +import Okapi + + +main :: IO () +main = runOkapi id 3000 calc + +type Okapi a = OkapiT IO a + +calc :: Okapi Response +calc = do + get + seg "calc" + addOp <|> subOp <|> mulOp <|> divOp + +addOp :: Okapi Response +addOp = do + seg "add" + (x, y) <- getArgs + respondJSON [] $ x + y + +subOp :: Okapi Response +subOp = do + seg "sub" <|> seg "minus" + (x, y) <- getArgs + respondJSON [] $ x - y + +mulOp :: Okapi Response +mulOp = do + seg "mul" + (x, y) <- getArgs + respondJSON [] $ x * y + +data DivResult = DivResult + { answer :: Int, + remainder :: Int + } + deriving (Eq, Show, Generic, ToJSON) + +divOp :: Okapi Response +divOp = do + seg "div" + (x, y) <- getArgs + if y == 0 + then abort401 [] "" + else respondJSON [] $ DivResult {answer = x `div` y, remainder = x `mod` y} + +getArgs :: Okapi (Int, Int) +getArgs = getArgsFromPath <|> getArgsFromQueryParams + where + getArgsFromPath :: Okapi (Int, Int) + getArgsFromPath = do + x <- segParamAs @Int + y <- segParamAs @Int + pure (x, y) + + getArgsFromQueryParams :: Okapi (Int, Int) + getArgsFromQueryParams = do + x <- queryParamAs @Int "x" + y <- queryParamAs @Int "y" + pure (x, y) diff --git a/examples/realworld/Conduit/Auth.hs b/examples/realworld/Conduit/Auth.hs new file mode 100644 index 0000000..9d54d3b --- /dev/null +++ b/examples/realworld/Conduit/Auth.hs @@ -0,0 +1,62 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TypeApplications #-} + +module Conduit.Auth where + +import Conduit.Type (EncodedUser (..), MonadHandler, User (..), grab) +import Data.Aeson +import Data.Char (isSpace) +import Data.Int +import Data.Map as Map (fromList, (!?)) +import Data.Text +import qualified Data.Text as Text +import Web.JWT as JWT + +userIdToToken :: MonadHandler m => Int32 -> m Text +userIdToToken userID = do + jwtSecret <- grab @Text + return $ encodeUserID jwtSecret userID + +encodeUser :: Text -> User -> EncodedUser +encodeUser secret User {..} = + EncodedUser + { encodedUserEmail = userEmail, + encodedUserToken = encodeUserID secret userID, + encodedUserUsername = userUsername, + encodedUserBio = userBio, + encodedUserImage = userImage, + encodedUserCreatedAt = userCreatedAt, + encodedUserUpdatedAt = userUpdatedAt + } + +encodeUserID :: Text -> Int32 -> Text +encodeUserID jwtSecret userID = + encodeSigned + (JWT.hmacSecret jwtSecret) + mempty + (mempty {unregisteredClaims = ClaimsMap $ Map.fromList [(jwtKey, toJSON userID)]}) + +tokenToUserID :: MonadHandler m => Text -> m (Maybe Int32) +tokenToUserID token = do + jwtSecret <- grab @Text + pure $ verifyToken jwtSecret token + +verifyToken :: Text -> Text -> Maybe Int32 +verifyToken jwtSecret token = do + jwt <- JWT.decodeAndVerifySignature (JWT.toVerify $ JWT.hmacSecret jwtSecret) token + result <- fromJSON <$> unClaimsMap (JWT.unregisteredClaims (JWT.claims jwt)) !? jwtKey + case result of + (Success userID) -> Just userID + _ -> Nothing + +jwtKey :: Text +jwtKey = "jwt" + +extractToken :: Text -> Maybe Text +extractToken auth + | toLower x == "token" = Just $ Text.dropWhile isSpace y + | otherwise = Nothing + where + (x, y) = Text.break isSpace auth diff --git a/examples/realworld/Conduit/Database.hs b/examples/realworld/Conduit/Database.hs new file mode 100644 index 0000000..928bd1a --- /dev/null +++ b/examples/realworld/Conduit/Database.hs @@ -0,0 +1,8 @@ +module Conduit.Database + ( module Conduit.Database.Query, + module Conduit.Database.Setup, + ) +where + +import Conduit.Database.Query +import Conduit.Database.Setup diff --git a/examples/realworld/Conduit/Database/Query.hs b/examples/realworld/Conduit/Database/Query.hs new file mode 100644 index 0000000..4329174 --- /dev/null +++ b/examples/realworld/Conduit/Database/Query.hs @@ -0,0 +1,628 @@ +{-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} + +module Conduit.Database.Query where + +import Conduit.Auth +import Conduit.Type +import Control.Monad (forM) +import Control.Monad.IO.Class (MonadIO, liftIO) +import Control.Monad.Reader.Class (MonadReader) +import Data.Bifunctor (bimap) +import Data.Either (rights) +import Data.Function ((&)) +import Data.Int +import Data.Profunctor (dimap) +import Data.Text +import Data.Time +import Data.Vector (Vector) +import qualified Data.Vector as Vector +import Hasql.Connection (Connection) +import Hasql.Session (QueryError, Session, statement) +import qualified Hasql.Session as Session +import Hasql.Statement (Statement) +import qualified Hasql.Statement as Statement +import Hasql.TH (resultlessStatement) +import qualified Hasql.TH as TH + +-- DB Functions + +loginUser :: MonadHandler m => Login -> m (Either QueryError EncodedUser) +loginUser loginData = do + result <- execSql (statement loginData sql) + case result of + Right user -> do + secret <- grab @Text + pure $ Right $ encodeUser secret user + Left queryError -> pure $ Left queryError + where + sql :: Statement Login User + sql = + dimap + loginToTuple + tupleToUser + [TH.singletonStatement| + select + pk_user :: int4, + email :: text, + username :: text, + bio :: text, + image :: text, + created_at :: timestamptz, + updated_at :: timestamptz + from users + where email = $1 :: text and hash = crypt($2 :: text, hash) + |] + + loginToTuple :: Login -> (Text, Text) + loginToTuple Login {..} = (loginEmail, loginPassword) + +registerUser :: MonadHandler m => Register -> m (Either QueryError EncodedUser) +registerUser register = do + result <- execSql (statement register sql) + case result of + Right user -> do + secret <- grab @Text + pure $ Right $ encodeUser secret user + Left queryError -> pure $ Left $ queryError + where + sql :: Statement Register User + sql = + dimap + registerToTuple + tupleToUser + [TH.singletonStatement| + insert into users (email, username, hash) + values ($1 :: text, $2 :: text, crypt($3 :: text, gen_salt('bf'))) + returning pk_user :: int4, email :: text, username :: text, bio :: text, image :: text, created_at :: timestamptz, updated_at :: timestamptz + |] + + registerToTuple :: Register -> (Text, Text, Text) + registerToTuple Register {..} = (registerEmail, registerUsername, registerPassword) + +tupleToUser :: (Int32, Text, Text, Text, Text, UTCTime, UTCTime) -> User +tupleToUser (userID, userEmail, userUsername, userBio, userImage, userCreatedAt, userUpdatedAt) = User {..} + +getCurrentUser :: MonadHandler m => Int32 -> m (Either QueryError EncodedUser) +getCurrentUser userID = do + result <- execSql (statement userID sql) + case result of + Right user -> do + secret <- grab @Text + pure $ Right $ encodeUser secret user + Left queryError -> pure $ Left $ queryError + where + sql :: Statement Int32 User + sql = + dimap + id + tupleToUser + [TH.singletonStatement| + select pk_user :: int, email :: text, username :: text, bio :: text, image :: text, created_at :: timestamptz, updated_at :: timestamptz + from users + where $1 :: int4 = pk_user + |] + +updateUser :: MonadHandler m => Int32 -> UpdateUser -> m (Either QueryError EncodedUser) +updateUser userID updateUser = do + result <- execSql (statement (userID, updateUser) sql) + case result of + Right user -> do + secret <- grab @Text + pure $ Right $ encodeUser secret user + Left queryError -> pure $ Left $ queryError + where + sql :: Statement (Int32, UpdateUser) User + sql = + dimap + userIDWithUpdateUserToTuple + tupleToUser + [TH.singletonStatement| + update users + set + email = coalesce($2 :: text?, email), + username = coalesce($3 :: text?, username), + bio = coalesce($5 :: text?, bio), + image = coalesce($6 :: text?, image), + hash = + case + when $4 :: text? is null then hash + else crypt($4 :: text?, gen_salt('bf')) + end + where pk_user = $1 :: int4 + returning pk_user :: int4, username :: text, email :: text, bio :: text, image :: text, created_at :: timestamptz, updated_at :: timestamptz + |] + + userIDWithUpdateUserToTuple :: (Int32, UpdateUser) -> (Int32, Maybe Text, Maybe Text, Maybe Text, Maybe Text, Maybe Text) + userIDWithUpdateUserToTuple (userID, UpdateUser {..}) = (userID, updateUserEmail, updateUserUsername, updateUserPassword, updateUserBio, updateUserImage) + +getProfile :: MonadHandler m => Maybe Int32 -> Text -> m (Either QueryError Profile) +getProfile mbUserID username = execSql (statement (mbUserID, username) sql) + where + sql :: Statement (Maybe Int32, Text) Profile + sql = + dimap + id + tupleToProfile + [TH.singletonStatement| + select + username :: text + , bio :: text + , image :: text + , ( exists + ( select follower_id :: int4 + from follows + inner join users on username = $2 :: text + where follower_id = $1 :: int4? + ) + ) :: bool + from users + where $2 :: text = username + |] + +tupleToProfile :: (Text, Text, Text, Bool) -> Profile +tupleToProfile (profileUsername, profileImage, profileBio, profileFollowing) = Profile {..} + +followUser :: MonadHandler m => Int32 -> Text -> m (Either QueryError Profile) +followUser userID username = do + execSql (statement (userID, username) sql) + getProfile (Just userID) username + where + sql :: Statement (Int32, Text) () + sql = + dimap + id + id + [TH.resultlessStatement| + insert into follows (follower_id, followee_id) + values ($1 :: int4, (select pk_user from users where users.username = $2 :: text)) + |] + +unfollowUser :: MonadHandler m => Int32 -> Text -> m (Either QueryError Profile) +unfollowUser userID username = do + execSql (statement (userID, username) sql) + getProfile (Just userID) username + where + sql :: Statement (Int32, Text) () + sql = + dimap + id + id + [TH.resultlessStatement| + delete from follows + where + $1 :: int4 = follower_id and (select pk_user from users where users.username = $2 :: text) = followee_id + |] + +getArticles :: MonadHandler m => Maybe Int32 -> ArticlesQuery -> m (Either QueryError Articles) +getArticles mbUserID articlesQuery = execSql (statement (mbUserID, articlesQuery) sql) + where + sql :: Statement (Maybe Int32, ArticlesQuery) Articles + sql = + dimap + articlesQueryToTuple + (articleListToArticles . Vector.toList . fmap tupleToArticle) + [TH.vectorStatement| + select + articles.slug :: text + , articles.title :: text + , articles.description :: text + , articles.body :: text + , (array(select tag from tags where tags.fk_article = articles.pk_article order by tag)) :: text[] + , (exists(select favorites.fk_user from favorites where favorites.fk_user = $1 :: int4? and favorites.fk_article = articles.pk_article)) :: bool + , (select count(*) from favorites favoritess where favoritess.fk_article = articles.pk_article) :: int4 + , articles.created_at :: timestamptz + , articles.updated_at :: timestamptz + , users.username :: text + , users.bio :: text + , users.image :: text + , ( exists + ( select follower_id :: int4 + from follows + inner join users userss on userss.username = users.username + where follower_id = $1 :: int4? + ) + ) :: bool + from articles + inner join users on articles.fk_user = users.pk_user + where + ($2 :: text? is null or (articles.pk_article in (select tags.fk_article from tags where tags.tag = $2 :: text? and tags.fk_article = articles.pk_article))) + and ($3 :: text? is null or $3 :: text? = users.username) + and ($4 :: text? is null or articles.pk_article in (select favoritesss.fk_article from favorites favoritesss inner join users userss on userss.username = $4 :: text? where favoritesss.fk_article = articles.pk_article)) + order by articles.created_at + limit $5 :: int4 + offset $6 :: int4 + |] + + articlesQueryToTuple :: (Maybe Int32, ArticlesQuery) -> (Maybe Int32, Maybe Text, Maybe Text, Maybe Text, Int32, Int32) + articlesQueryToTuple (mbUserID, ArticlesQuery {..}) = + ( mbUserID, + articlesQueryTag, + articlesQueryAuthor, + articlesQueryFavorited, + articlesQueryLimit, + articlesQueryOffset + ) + +articleListToArticles :: [Article] -> Articles +articleListToArticles articlesArticleList = let articlesArticlesCount = Prelude.length articlesArticleList in Articles {..} + +feedArticles :: MonadHandler m => Int32 -> Int32 -> Int32 -> m (Either QueryError Articles) +feedArticles userID limit offset = execSql (statement (userID, limit, offset) sql) + where + sql :: Statement (Int32, Int32, Int32) Articles + sql = + dimap + id + (articleListToArticles . Vector.toList . fmap tupleToArticle) + [TH.vectorStatement| + select + slug :: text + , title :: text + , description :: text + , body :: text + , (array(select tag from tags where tags.fk_article = articles.pk_article)) :: text[] + , true :: bool + , (select count(*) from favorites where favorites.fk_article = articles.pk_article) :: int4 + , articles.created_at :: timestamptz + , articles.updated_at :: timestamptz + , users.username :: text + , users.bio :: text + , users.image :: text + , ( exists + ( select follower_id :: int4 + from follows + inner join users userss on userss.username = users.username + where follower_id = $1 :: int4 + ) + ) :: bool + from articles + inner join users on articles.fk_user = users.pk_user + inner join favorites favoritess on articles.pk_article = favoritess.fk_article and favoritess.fk_user = $1 :: int4 + order by articles.created_at + limit $2 :: int4 + offset $3 :: int4 + |] + +getArticle :: MonadHandler m => Text -> m (Either QueryError Article) +getArticle slug = execSql (statement slug sql) + where + sql :: Statement Text Article + sql = + dimap + id + tupleToArticle + [TH.singletonStatement| + select + slug :: text + , title :: text + , description :: text + , body :: text + , (array(select tag from tags where tags.fk_article = articles.pk_article)) :: text[] + , false :: bool + , (select count(*) from favorites where favorites.fk_article = articles.pk_article) :: int4 + , articles.created_at :: timestamptz + , articles.updated_at :: timestamptz + , users.username :: text + , users.bio :: text + , users.image :: text + , false :: bool + from articles + inner join users on articles.fk_user = users.pk_user + where articles.slug = $1 :: text + |] + +getArticleByID :: MonadHandler m => Maybe Int32 -> Int32 -> m (Either QueryError Article) +getArticleByID mbUserID articleID = execSql (statement (mbUserID, articleID) sql) + where + sql :: Statement (Maybe Int32, Int32) Article + sql = + dimap + id + tupleToArticle + [TH.singletonStatement| + select + slug :: text + , title :: text + , description :: text + , body :: text + , (array(select tag from tags where tags.fk_article = articles.pk_article)) :: text[] + , (exists(select favorites.fk_user from favorites where favorites.fk_user = $1 :: int4? and favorites.fk_article = articles.pk_article)) :: bool + , (select count(*) from favorites where favorites.fk_article = articles.pk_article) :: int4 + , articles.created_at :: timestamptz + , articles.updated_at :: timestamptz + , users.username :: text + , users.bio :: text + , users.image :: text + , ( exists + ( select follower_id :: int4 + from follows + inner join users userss on userss.username = users.username + where follower_id = $1 :: int4? + ) + ) :: bool + from articles + inner join users on articles.fk_user = users.pk_user + where articles.pk_article = $2 :: int4 + |] + +tupleToArticle :: + ( Text, + Text, + Text, + Text, + Vector Text, + Bool, + Int32, + UTCTime, + UTCTime, + Text, + Text, + Text, + Bool + ) -> + Article +tupleToArticle + ( articleSlug, + articleTitle, + articleDescription, + articleBody, + articleTagVector, + articleFavorited, + articleFavoritesCount, + articleCreatedAt, + articleUpdatedAt, + authorUsername, + authorBio, + authorImage, + authorFollowing + ) = + let articleTagList = Vector.toList articleTagVector + articleAuthor = Author {..} + in Article {..} + +createTags :: MonadHandler m => Int32 -> Maybe [Text] -> m (Either QueryError ()) +createTags articleID mbTags = case mbTags of + Nothing -> pure $ Right () + Just tags -> execSql (statement (bimap Vector.fromList Vector.fromList $ unzip $ fmap (\tag -> (articleID, tag)) tags) sql) + where + sql :: Statement (Vector Int32, Vector Text) () + sql = + dimap + id + id + [TH.resultlessStatement| + insert into tags (fk_article, tag) + select * from unnest ($1 :: int4[], $2 :: text[]) + |] + +createArticle :: MonadHandler m => Int32 -> CreateArticle -> m (Either QueryError Article) +createArticle userID createArticleData = do + eitherArticleID <- execSql (statement (userID, createArticleData) sql) + case eitherArticleID of + Left queryError -> pure $ Left queryError + Right articleID -> do + createTags articleID $ createArticleTagList createArticleData + getArticleByID (Just userID) articleID + where + sql :: Statement (Int32, CreateArticle) Int32 + sql = + dimap + userIDWithCreateArticleToTuple + id + [TH.singletonStatement| + insert into articles (fk_user, slug, title, description, body) + values ($1 :: int4, $2 :: text, $3 :: text, $4 :: text, $5 :: text) + returning pk_article :: int4 + |] + + userIDWithCreateArticleToTuple :: (Int32, CreateArticle) -> (Int32, Text, Text, Text, Text) + userIDWithCreateArticleToTuple (userID, CreateArticle {..}) = + (userID, slugify createArticleTitle, createArticleTitle, createArticleDescription, createArticleBody) + +slugify :: Text -> Text +slugify = id + +updateArticle :: MonadHandler m => Int32 -> Text -> UpdateArticle -> m (Either QueryError Article) +updateArticle userID slug updateArticleData = do + eitherArticleID <- execSql (statement (userID, slug, updateArticleData) sql) + case eitherArticleID of + Left queryError -> pure $ Left queryError + Right articleID -> getArticleByID (Just userID) articleID + where + sql :: Statement (Int32, Text, UpdateArticle) Int32 + sql = + dimap + userIDWithUpdateArticleToTuple + id + [TH.singletonStatement| + update articles + set + title = coalesce($3 :: text?, title), + slug = coalesce($4 :: text?, slug), + description = coalesce($5 :: text?, description), + body = coalesce($6 :: text?, body) + where fk_user = $1 :: int4 and slug = $2 :: text + returning pk_article :: int4 + |] + + userIDWithUpdateArticleToTuple :: (Int32, Text, UpdateArticle) -> (Int32, Text, Maybe Text, Maybe Text, Maybe Text, Maybe Text) + userIDWithUpdateArticleToTuple (userID, slug, UpdateArticle {..}) = + (userID, slug, slugify <$> updateArticleTitle, updateArticleTitle, updateArticleDescription, updateArticleBody) + +deleteArticle :: MonadHandler m => Int32 -> Text -> m (Either QueryError ()) +deleteArticle userID slug = execSql (statement (userID, slug) sql) + where + sql :: Statement (Int32, Text) () + sql = + dimap + id + id + [TH.resultlessStatement| + delete from articles + where $1 :: int4 = fk_user and $2 :: text = slug + |] + +getCommentByID :: MonadHandler m => Int32 -> m (Either QueryError Comment) +getCommentByID commentID = execSql (statement commentID sql) + where + sql :: Statement Int32 Comment + sql = + dimap + id + tupleToComment + [TH.singletonStatement| + select + pk_comment :: int4, + comments.created_at :: timestamptz, + comments.updated_at :: timestamptz, + body :: text, + users.username :: text, + users.bio :: text, + users.image :: text, + false :: bool + from comments + inner join users on users.pk_user = comments.fk_user + where $1 :: int4 = pk_comment + |] + +createComment :: MonadHandler m => Int32 -> Text -> CreateComment -> m (Either QueryError Comment) +createComment userID slug createCommentData = do + eitherCommentID <- execSql (statement (userID, slug, createCommentData) sql) + case eitherCommentID of + Left queryError -> pure $ Left queryError + Right commentID -> getCommentByID commentID + where + sql :: Statement (Int32, Text, CreateComment) Int32 + sql = + dimap + userIDAndSlugWithCreateCommentToTuple + id + [TH.singletonStatement| + insert into comments (fk_user, fk_article, body) + values ($1 :: int4, (select pk_article from articles where slug = $2 :: text), $3 :: text) + returning pk_comment :: int4 + |] + + userIDAndSlugWithCreateCommentToTuple :: (Int32, Text, CreateComment) -> (Int32, Text, Text) + userIDAndSlugWithCreateCommentToTuple (userID, slug, CreateComment {..}) = + (userID, slug, createCommentBody) + +tupleToComment :: (Int32, UTCTime, UTCTime, Text, Text, Text, Text, Bool) -> Comment +tupleToComment (commentID, commentCreatedAt, commentUpdatedAt, commentBody, authorUsername, authorBio, authorImage, authorFollowing) = + let commentAuthor = Author {..} + in Comment {..} + +getComments :: MonadHandler m => Maybe Int32 -> Text -> m (Either QueryError Comments) +getComments mbUserID slug = execSql (statement (mbUserID, slug) sql) + where + sql :: Statement (Maybe Int32, Text) Comments + sql = + dimap + id + (Comments . Vector.toList . fmap tupleToComment) + [TH.vectorStatement| + select + pk_comment :: int4, + comments.created_at :: timestamptz, + comments.updated_at :: timestamptz, + comments.body :: text, + users.username :: text, + users.bio :: text, + users.image :: text, + ( exists + ( select follower_id :: int4 + from follows + inner join users userss on userss.username = users.username + where follower_id = $1 :: int4? + ) + ) :: bool + from comments + inner join users on users.pk_user = comments.fk_user + inner join articles on articles.pk_article = comments.fk_article + where $2 :: text = articles.slug + order by comments.created_at + |] + +deleteComment :: MonadHandler m => Int32 -> Text -> Int32 -> m (Either QueryError ()) +deleteComment userID _ commentID = execSql (statement (userID, commentID) sql) + where + sql :: Statement (Int32, Int32) () + sql = + dimap + id + id + [TH.resultlessStatement| + delete from comments + where $1 :: int4 = fk_user and $2 :: int4 = pk_comment + |] + +favoriteArticle :: MonadHandler m => Int32 -> Text -> m (Either QueryError Article) +favoriteArticle userID slug = do + eitherArticleID <- execSql (statement (userID, slug) sql) + case eitherArticleID of + Left queryError -> pure $ Left queryError + Right articleID -> getArticleByID (Just userID) articleID + where + sql :: Statement (Int32, Text) Int32 + sql = + dimap + id + id + [TH.singletonStatement| + insert into favorites (fk_user, fk_article) + values ($1 :: int4, (select pk_article from articles where slug = $2 :: text)) + returning favorites.fk_article :: int4 + |] + +unfavoriteArticle :: MonadHandler m => Int32 -> Text -> m (Either QueryError Article) +unfavoriteArticle userID slug = do + eitherArticleID <- execSql (statement (userID, slug) sql) + case eitherArticleID of + Left queryError -> pure $ Left queryError + Right articleID -> getArticleByID (Just userID) articleID + where + sql :: Statement (Int32, Text) Int32 + sql = + dimap + id + id + [TH.singletonStatement| + delete from favorites + using articles + where favorites.fk_user = $1 :: int4 and articles.slug = $2 :: text and articles.pk_article = favorites.fk_article + returning favorites.fk_article :: int4 + |] + +getTags :: MonadHandler m => m (Either QueryError Tags) +getTags = execSql (statement () sql) + where + sql :: Statement () Tags + sql = + dimap + id + (Tags . Vector.toList) + [TH.vectorStatement| + select tag :: text from tags + |] + +deleteTags :: MonadHandler m => Int32 -> m (Either QueryError ()) +deleteTags articleID = execSql (statement articleID sql) + where + sql :: Statement Int32 () + sql = + dimap + id + id + [TH.resultlessStatement| + delete from tags where fk_article = $1 :: int4 + |] + +execSql :: MonadHandler m => Session a -> m (Either QueryError a) +execSql session = do + conn <- grab @Connection + liftIO $ Session.run session conn diff --git a/examples/realworld/Conduit/Database/Setup.hs b/examples/realworld/Conduit/Database/Setup.hs new file mode 100644 index 0000000..99b275b --- /dev/null +++ b/examples/realworld/Conduit/Database/Setup.hs @@ -0,0 +1,115 @@ +{-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} + +module Conduit.Database.Setup where + +import Conduit.Auth +import Conduit.Type +import Control.Monad (forM) +import Control.Monad.IO.Class (MonadIO, liftIO) +import Control.Monad.Reader.Class (MonadReader) +import Data.Bifunctor (bimap) +import Data.Either (rights) +import Data.Function ((&)) +import Data.Int +import Data.Profunctor (dimap) +import Data.Text +import Data.Time +import Data.Vector (Vector) +import qualified Data.Vector as Vector +import Hasql.Connection (Connection) +import Hasql.Session (QueryError, Session, statement) +import qualified Hasql.Session as Session +import Hasql.Statement (Statement) +import qualified Hasql.Statement as Statement +import Hasql.TH (resultlessStatement) +import qualified Hasql.TH as TH + +setupDB :: Connection -> IO () +setupDB conn = do + let create = + Session.sql + [TH.uncheckedSql| + CREATE OR REPLACE FUNCTION update_updated_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = now(); + RETURN NEW; + END; + $$ language 'plpgsql'; + + create table if not exists users ( + pk_user serial primary key, + username text unique not null, + email text unique not null, + image text not null default 'https://api.realworld.io/images/smiley-cyrus.jpeg', + bio text not null default '', + hash text not null, + created_at timestamp with time zone default current_timestamp, + updated_at timestamp with time zone default current_timestamp + ); + + create trigger update_users before update on users for each row execute procedure update_updated_at(); + + create table if not exists follows ( + follower_id serial references users(pk_user) on delete cascade, + followee_id serial references users(pk_user) on delete cascade, + constraint pk_follows primary key (follower_id, followee_id) + ); + + create table if not exists articles ( + pk_article serial primary key, + fk_user serial references users(pk_user) on delete cascade, + title text unique not null, + slug text unique not null, + description text unique not null, + body text unique not null, + created_at timestamp with time zone default current_timestamp, + updated_at timestamp with time zone default current_timestamp + ); + + create trigger update_articles before update on articles for each row execute procedure update_updated_at(); + + create table if not exists comments ( + pk_comment serial primary key, + fk_user serial references users(pk_user) on delete cascade, + fk_article serial references articles(pk_article) on delete cascade, + body text unique not null, + created_at timestamp with time zone default current_timestamp, + updated_at timestamp with time zone default current_timestamp + ); + + create trigger update_comments before update on comments for each row execute procedure update_updated_at(); + + create table if not exists favorites ( + fk_user serial references users(pk_user) on delete cascade, + fk_article serial references articles(pk_article) on delete cascade, + constraint pk_favorites primary key (fk_user, fk_article) + ); + + create table if not exists tags ( + tag text not null, + fk_article serial references articles(pk_article) on delete cascade, + constraint pk_tags primary key (tag, fk_article) + ); + |] + destroy = + Session.sql + [TH.uncheckedSql| + drop table if exists users, articles, comments, favorites, tags, follows cascade; + drop trigger if exists update_updated_at on users; + drop trigger if exists update_updated_at on articles; + drop trigger if exists update_updated_at on comments; + drop function if exists update_update_at; + |] + + result <- Session.run destroy conn + print "Destroying tables..." + print result + print "Making tables..." + result <- Session.run create conn + print result diff --git a/examples/realworld/Conduit/Server.hs b/examples/realworld/Conduit/Server.hs new file mode 100644 index 0000000..d4f6b10 --- /dev/null +++ b/examples/realworld/Conduit/Server.hs @@ -0,0 +1,196 @@ +{-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TypeApplications #-} + +module Conduit.Server where + +import Conduit.Auth +import qualified Conduit.Database as DB +import Conduit.Type +import Control.Applicative +import Control.Monad.Combinators +import Control.Monad.Except +import Control.Monad.Trans.Reader +import Data.Aeson +import Data.Int +import Data.Text +import Data.Time +import GHC.Generics +import Hasql.Session (QueryError) +import Okapi + +type Okapi a = OkapiT Handler a + +conduit :: Okapi Response +conduit = do + seg "api" + choice + [ users, + user, + profiles, + articles, + tags + ] + +users = do + post + seg "users" + login <|> register + +login = do + seg "login" + loginData <- bodyJSON @Login + handleQuery $ DB.loginUser loginData + +register = do + registerData <- bodyJSON @Register + handleQuery $ DB.registerUser registerData + +user = do + seg "user" + userID <- authorize + currentUser userID <|> updateUser userID + +currentUser userID = do + get + handleQuery $ DB.getCurrentUser userID + +updateUser userID = do + put + updateUserData <- bodyJSON @UpdateUser + handleQuery $ DB.updateUser userID updateUserData + +profiles = do + seg "profiles" + username <- segParam + profile username <|> (seg "follow" >> follow username <|> unfollow username) + +profile username = do + get + mbUserID <- optional authorize + handleQuery $ DB.getProfile mbUserID username + +follow username = do + post + userID <- authorize + handleQuery $ DB.followUser userID username + +unfollow username = do + delete + userID <- authorize + handleQuery $ DB.unfollowUser userID username + +articles = do + seg "articles" + choice + [ get + >> choice + [ feed, + comments, + article, + global + ], + post + >> choice + [ createComment, + favoriteArticle, + createArticle + ], + updateArticle, + delete + >> choice + [ deleteComment, + unfavoriteArticle, + deleteArticle + ] + ] + +global = do + mbUserID <- optional authorize + articlesQueryTag <- optional $ queryParam "tag" + articlesQueryAuthor <- optional $ queryParam "author" + articlesQueryFavorited <- optional $ queryParam "favorited" + articlesQueryLimit <- option 20 $ queryParamAs @Int32 "limit" + articlesQueryOffset <- option 0 $ queryParamAs @Int32 "offset" + handleQuery $ DB.getArticles mbUserID ArticlesQuery {..} + +feed = do + seg "feed" + userID <- authorize + limit <- option 20 $ queryParamAs @Int32 "limit" + offset <- option 0 $ queryParamAs @Int32 "offset" + handleQuery $ DB.feedArticles userID limit offset + +article = do + slug <- segParam + handleQuery $ DB.getArticle slug + +comments = do + slug <- segParam + seg "comments" + mbUserID <- optional authorize + handleQuery $ DB.getComments mbUserID slug + +createArticle = do + userID <- authorize + createArticleData <- bodyJSON @CreateArticle + handleQuery $ DB.createArticle userID createArticleData + +createComment = do + slug <- segParam + seg "comments" + userID <- authorize + createCommentData <- bodyJSON @CreateComment + handleQuery $ DB.createComment userID slug createCommentData + +favoriteArticle = do + slug <- segParam + seg "favorite" + userID <- authorize + handleQuery $ DB.favoriteArticle userID slug + +updateArticle = do + put + slug <- segParam + userID <- authorize + updateArticleData <- bodyJSON @UpdateArticle + handleQuery $ DB.updateArticle userID slug updateArticleData + +deleteArticle = do + slug <- segParam + userID <- authorize + handleQuery $ DB.deleteArticle userID slug + +deleteComment = do + slug <- segParam + seg "comments" + commentID <- segParamAs @Int32 + userID <- authorize + handleQuery $ DB.deleteComment userID slug commentID + +unfavoriteArticle = do + slug <- segParam + seg "favorite" + userID <- authorize + handleQuery $ DB.unfavoriteArticle userID slug + +tags = do + get + seg "tags" + handleQuery DB.getTags + +authorize = do + authHeaderValue <- auth + jwtSecret <- grab @Text + case extractToken authHeaderValue >>= verifyToken jwtSecret of + Nothing -> abort401 [] "" + Just userID -> pure userID + +handleQuery :: ToJSON a => Okapi (Either QueryError a) -> Okapi Response +handleQuery query = do + queryResult <- query + case queryResult of + Left _ -> abort422 [] genericError + Right value -> respondJSON [] value diff --git a/examples/realworld/Conduit/Type.hs b/examples/realworld/Conduit/Type.hs new file mode 100644 index 0000000..204aae1 --- /dev/null +++ b/examples/realworld/Conduit/Type.hs @@ -0,0 +1,63 @@ +{-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveFunctor #-} +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE MultiParamTypeClasses #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} + +module Conduit.Type + ( module Conduit.Type.Request, + module Conduit.Type.Response, + Handler (..), + Config (..), + MonadHandler, + grab, + Has (..), + ) +where + +import Conduit.Type.Request +import Conduit.Type.Response +import Control.Monad.Except (MonadError) +import Control.Monad.IO.Class (MonadIO) +import Control.Monad.Reader.Class (MonadReader, asks) +import Control.Monad.State.Class (MonadState) +import Control.Monad.Trans.Reader (ReaderT, runReaderT) +import Data.Text +import Hasql.Connection (Connection) + +data Config = Config + { configJWTSecret :: Text, + configDBConnection :: Connection + } + +class Has field env where + obtain :: env -> field + +instance Has Connection Config where + obtain = configDBConnection + +instance Has Text Config where + obtain = configJWTSecret + +grab :: forall field env m. (MonadReader env m, Has field env) => m field +grab = asks $ obtain @field + +newtype Handler a = Handler {runHandler :: ReaderT Config IO a} + deriving newtype + ( Functor, + Applicative, + Monad, + MonadReader Config, + MonadIO + ) + +type MonadHandler m = + ( Monad m, + MonadReader Config m, + MonadIO m + ) diff --git a/examples/realworld/Conduit/Type/Request.hs b/examples/realworld/Conduit/Type/Request.hs new file mode 100644 index 0000000..764529c --- /dev/null +++ b/examples/realworld/Conduit/Type/Request.hs @@ -0,0 +1,106 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TypeApplications #-} + +module Conduit.Type.Request where + +import Control.Applicative +import Control.Monad.Combinators +import Data.Aeson +import Data.Int +import Data.Text +import Data.Time +import GHC.Generics + +data Login = Login + { loginEmail :: Text, + loginPassword :: Text + } + +instance FromJSON Login where + parseJSON = withObject "login" $ \o -> do + userO <- o .: "user" + loginEmail <- userO .: "email" + loginPassword <- userO .: "password" + pure Login {..} + +data Register = Register + { registerEmail :: Text, + registerUsername :: Text, + registerPassword :: Text + } + +instance FromJSON Register where + parseJSON = withObject "register" $ \o -> do + userO <- o .: "user" + registerEmail <- userO .: "email" + registerUsername <- userO .: "username" + registerPassword <- userO .: "password" + pure Register {..} + +data UpdateUser = UpdateUser + { updateUserEmail :: Maybe Text, + updateUserUsername :: Maybe Text, + updateUserPassword :: Maybe Text, + updateUserBio :: Maybe Text, + updateUserImage :: Maybe Text + } + +instance FromJSON UpdateUser where + parseJSON = withObject "updateUser" $ \o -> do + userO <- o .: "user" + updateUserEmail <- userO .:? "email" + updateUserUsername <- userO .:? "username" + updateUserPassword <- userO .:? "password" + updateUserImage <- userO .:? "image" + updateUserBio <- userO .:? "bio" + pure UpdateUser {..} + +data ArticlesQuery = ArticlesQuery + { articlesQueryTag :: Maybe Text, + articlesQueryAuthor :: Maybe Text, + articlesQueryFavorited :: Maybe Text, + articlesQueryLimit :: Int32, + articlesQueryOffset :: Int32 + } + +data CreateArticle = CreateArticle + { createArticleTitle :: Text, + createArticleDescription :: Text, + createArticleBody :: Text, + createArticleTagList :: Maybe [Text] + } + +instance FromJSON CreateArticle where + parseJSON = withObject "createArticle" $ \o -> do + articleO <- o .: "article" + createArticleTitle <- articleO .: "title" + createArticleDescription <- articleO .: "description" + createArticleBody <- articleO .: "body" + createArticleTagList <- articleO .:? "tagList" + pure CreateArticle {..} + +data UpdateArticle = UpdateArticle + { updateArticleTitle :: Maybe Text, + updateArticleDescription :: Maybe Text, + updateArticleBody :: Maybe Text + } + +instance FromJSON UpdateArticle where + parseJSON = withObject "updateArticle" $ \o -> do + articleO <- o .: "article" + updateArticleTitle <- articleO .:? "title" + updateArticleDescription <- articleO .:? "description" + updateArticleBody <- articleO .:? "body" + pure UpdateArticle {..} + +newtype CreateComment = CreateComment + { createCommentBody :: Text + } + +instance FromJSON CreateComment where + parseJSON = withObject "createComment" $ \o -> do + commentO <- o .: "comment" + createCommentBody <- commentO .: "body" + pure CreateComment {..} diff --git a/examples/realworld/Conduit/Type/Response.hs b/examples/realworld/Conduit/Type/Response.hs new file mode 100644 index 0000000..35300b1 --- /dev/null +++ b/examples/realworld/Conduit/Type/Response.hs @@ -0,0 +1,329 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TypeApplications #-} + +module Conduit.Type.Response where + +import Control.Applicative +import Control.Monad.Combinators +import Data.Aeson +import Data.Int +import Data.Text +import Data.Time +import GHC.Generics + +data User = User + { userID :: Int32, + userEmail :: Text, + -- userToken :: Text, + userUsername :: Text, + userBio :: Text, + userImage :: Text, + userCreatedAt :: UTCTime, + userUpdatedAt :: UTCTime + } + deriving (Eq, Show, Generic) + +data EncodedUser = EncodedUser + { encodedUserEmail :: Text, + encodedUserToken :: Text, + encodedUserUsername :: Text, + encodedUserBio :: Text, + encodedUserImage :: Text, + encodedUserCreatedAt :: UTCTime, + encodedUserUpdatedAt :: UTCTime + } + deriving (Eq, Show, Generic) + +{- +{ + "user": { + "email": "jake@jake.jake", + "token": "jwt.token.here", + "username": "jake", + "bio": "I work at statefarm", + "image": null + } +} +-} + +instance ToJSON EncodedUser where + toJSON EncodedUser {..} = + object + [ "user" + .= object + [ "email" .= encodedUserEmail, + "token" .= encodedUserToken, + "username" .= encodedUserUsername, + "bio" .= encodedUserBio, + "image" .= encodedUserImage + ] + ] + +{- +{ + "profile": { + "username": "jake", + "bio": "I work at statefarm", + "image": "https://api.realworld.io/images/smiley-cyrus.jpg", + "following": false + } +} +-} + +data Profile = Profile + { profileUsername :: Text, + profileBio :: Text, + profileImage :: Text, + profileFollowing :: Bool + } + deriving (Eq, Show, Generic) + +instance ToJSON Profile where + toJSON Profile {..} = + object + [ "profile" + .= object + [ "username" .= profileUsername, + "bio" .= profileBio, + "image" .= profileImage, + "following" .= profileFollowing + ] + ] + +{- +{ + "article": { + "slug": "how-to-train-your-dragon", + "title": "How to train your dragon", + "description": "Ever wonder how?", + "body": "It takes a Jacobian", + "tagList": ["dragons", "training"], + "createdAt": "2016-02-18T03:22:56.637Z", + "updatedAt": "2016-02-18T03:48:35.824Z", + "favorited": false, + "favoritesCount": 0, + "author": { + "username": "jake", + "bio": "I work at statefarm", + "image": "https://i.stack.imgur.com/xHWG8.jpg", + "following": false + } + } +} +-} + +data Author = Author + { authorUsername :: Text, + authorBio :: Text, + authorImage :: Text, + authorFollowing :: Bool + } + deriving (Eq, Show, Generic) + +instance ToJSON Author where + toJSON Author {..} = + object + [ "username" .= authorUsername, + "bio" .= authorBio, + "image" .= authorImage, + "following" .= authorFollowing + ] + +data Article = Article + { articleSlug :: Text, + articleTitle :: Text, + articleDescription :: Text, + articleBody :: Text, + articleTagList :: [Text], + articleCreatedAt :: UTCTime, + articleUpdatedAt :: UTCTime, + articleFavorited :: Bool, + articleFavoritesCount :: Int32, + articleAuthor :: Author + } + deriving (Eq, Show, Generic) + +instance ToJSON Article where + toJSON Article {..} = + object + [ "article" + .= object + [ "slug" .= articleSlug, + "title" .= articleTitle, + "description" .= articleDescription, + "body" .= articleBody, + "tagList" .= articleTagList, + "createdAt" .= articleCreatedAt, + "updatedAt" .= articleUpdatedAt, + "favorited" .= articleFavorited, + "favoritesCount" .= articleFavoritesCount, + "author" .= articleAuthor + ] + ] + +{- +{ + "articles":[{ + "slug": "how-to-train-your-dragon", + "title": "How to train your dragon", + "description": "Ever wonder how?", + "body": "It takes a Jacobian", + "tagList": ["dragons", "training"], + "createdAt": "2016-02-18T03:22:56.637Z", + "updatedAt": "2016-02-18T03:48:35.824Z", + "favorited": false, + "favoritesCount": 0, + "author": { + "username": "jake", + "bio": "I work at statefarm", + "image": "https://i.stack.imgur.com/xHWG8.jpg", + "following": false + } + }, { + "slug": "how-to-train-your-dragon-2", + "title": "How to train your dragon 2", + "description": "So toothless", + "body": "It a dragon", + "tagList": ["dragons", "training"], + "createdAt": "2016-02-18T03:22:56.637Z", + "updatedAt": "2016-02-18T03:48:35.824Z", + "favorited": false, + "favoritesCount": 0, + "author": { + "username": "jake", + "bio": "I work at statefarm", + "image": "https://i.stack.imgur.com/xHWG8.jpg", + "following": false + } + }], + "articlesCount": 2 +} +-} + +data Articles = Articles + { articlesArticleList :: [Article], + articlesArticlesCount :: Int + } + deriving (Eq, Show, Generic) + +instance ToJSON Articles where + toJSON Articles {..} = + object + [ "articles" + .= fmap + ( \Article {..} -> + object + [ "slug" .= articleSlug, + "title" .= articleTitle, + "description" .= articleDescription, + "body" .= articleBody, + "tagList" .= articleTagList, + "createdAt" .= articleCreatedAt, + "updatedAt" .= articleUpdatedAt, + "favorited" .= articleFavorited, + "favoritesCount" .= articleFavoritesCount, + "author" .= articleAuthor + ] + ) + articlesArticleList, + "articlesCount" .= articlesArticlesCount + ] + +{- +{ + "comment": { + "id": 1, + "createdAt": "2016-02-18T03:22:56.637Z", + "updatedAt": "2016-02-18T03:22:56.637Z", + "body": "It takes a Jacobian", + "author": { + "username": "jake", + "bio": "I work at statefarm", + "image": "https://i.stack.imgur.com/xHWG8.jpg", + "following": false + } + } +} +-} + +data Comment = Comment + { commentID :: Int32, + commentCreatedAt :: UTCTime, + commentUpdatedAt :: UTCTime, + commentBody :: Text, + commentAuthor :: Author + } + deriving (Eq, Show, Generic) + +instance ToJSON Comment where + toJSON Comment {..} = + object + [ "comment" + .= object + [ "id" .= commentID, + "createdAt" .= commentCreatedAt, + "updatedAt" .= commentUpdatedAt, + "body" .= commentBody, + "author" .= commentAuthor + ] + ] + +newtype Comments = Comments + {commentsCommentList :: [Comment]} + deriving (Eq, Show, Generic) + +instance ToJSON Comments where + toJSON Comments {..} = + object + [ "comments" + .= fmap + ( \Comment {..} -> + object + [ "id" .= commentID, + "createdAt" .= commentCreatedAt, + "updatedAt" .= commentUpdatedAt, + "body" .= commentBody, + "author" .= commentAuthor + ] + ) + commentsCommentList + ] + +newtype Tags = Tags + {tagsTagList :: [Text]} + deriving (Eq, Show, Generic) + +instance ToJSON Tags where + toJSON Tags {..} = + object + [ "tags" .= tagsTagList + ] + +{- +{ + "errors":{ + "body": [ + "can't be empty" + ] + } +} +-} + +newtype GenericError = GenericError + { genericErrorErrors :: [Text] + } + deriving (Eq, Show, Generic) + +genericError = encode $ GenericError ["Something happened...", "Try again."] + +instance ToJSON GenericError where + toJSON GenericError {..} = + object + [ "errors" + .= object + [ "body" .= genericErrorErrors + ] + ] diff --git a/examples/realworld/Main.hs b/examples/realworld/Main.hs new file mode 100644 index 0000000..0a8138d --- /dev/null +++ b/examples/realworld/Main.hs @@ -0,0 +1,42 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TypeApplications #-} + +module Main where + +import Conduit.Database +import Conduit.Server +import Conduit.Type +import Control.Monad.Reader (runReaderT) +import qualified Hasql.Connection as Connection +import Okapi +import System.Random +import qualified Data.Text as Text + +main :: IO () +main = do + config <- getConfig + setupDB $ configDBConnection config + runOkapi (hoistHandler config) 3000 conduit + +hoistHandler :: Config -> Handler a -> IO a +hoistHandler config app = runReaderT (runHandler app) config + +getConfig :: IO Config +getConfig = do + let dbConnSettings = Connection.settings + "localhost" + 5432 + "realworld" + "abc" + "realworld_api" + connResult <- Connection.acquire dbConnSettings + + case connResult of + Left err -> error $ show err + Right configDBConnection -> do + gen <- newStdGen + let configJWTSecret = Text.pack $ take 50 $ randoms gen + print "Config generated successfully" + pure $ Config {..} diff --git a/examples/todo/Main.hs b/examples/todo/Main.hs new file mode 100644 index 0000000..688326b --- /dev/null +++ b/examples/todo/Main.hs @@ -0,0 +1,17 @@ +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} + +module Main where + +import Control.Applicative ((<|>)) +import Data.Aeson (ToJSON) +import Data.ByteString.Lazy (fromStrict) +import Data.Text +import Data.Text.Encoding (encodeUtf8) +import GHC.Generics (Generic) +import Okapi + +main :: IO () +main = print "todo" diff --git a/okapi.cabal b/okapi.cabal index bf8f6e6..24c9c70 100644 --- a/okapi.cabal +++ b/okapi.cabal @@ -49,12 +49,78 @@ library , warp-tls default-language: Haskell2010 -executable okapi-exe +executable calc-exe main-is: Main.hs other-modules: Paths_okapi hs-source-dirs: - app + examples/calc + ghc-options: -threaded -rtsopts -with-rtsopts=-N + build-depends: + aeson + , base >=4.7 && <5 + , base64 + , bytestring + , http-api-data + , http-types + , mmorph + , mtl + , okapi + , random + , text + , transformers + , wai + , warp + , warp-tls + default-language: Haskell2010 + +executable realworld-exe + main-is: Main.hs + other-modules: + Conduit.Auth + Conduit.Database + Conduit.Database.Query + Conduit.Database.Setup + Conduit.Server + Conduit.Type + Conduit.Type.Request + Conduit.Type.Response + Paths_okapi + hs-source-dirs: + examples/realworld + ghc-options: -threaded -rtsopts -with-rtsopts=-N + build-depends: + aeson + , base >=4.7 && <5 + , base64 + , bytestring + , containers + , hasql + , hasql-th + , http-api-data + , http-types + , jwt + , mmorph + , mtl + , okapi + , parser-combinators + , profunctors + , random + , text + , time + , transformers + , vector + , wai + , warp + , warp-tls + default-language: Haskell2010 + +executable todo-exe + main-is: Main.hs + other-modules: + Paths_okapi + hs-source-dirs: + examples/todo ghc-options: -threaded -rtsopts -with-rtsopts=-N build-depends: aeson diff --git a/openapi.yml b/openapi.yml new file mode 100644 index 0000000..f553cdd --- /dev/null +++ b/openapi.yml @@ -0,0 +1,916 @@ +openapi: 3.0.1 +info: + title: Conduit API + description: Conduit API + contact: + name: RealWorld + url: https://realworld.io + license: + name: MIT License + url: https://opensource.org/licenses/MIT + version: 1.0.0 +servers: + - url: /api +paths: + /users/login: + post: + tags: + - User and Authentication + summary: Existing user login + description: Login for existing user + operationId: Login + requestBody: + description: Credentials to use + content: + application/json: + schema: + $ref: '#/components/schemas/LoginUserRequest' + required: true + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + x-codegen-request-body-name: body + /users: + post: + tags: + - User and Authentication + summary: Register a new user + description: Register a new user + operationId: CreateUser + requestBody: + description: Details of the new user to register + content: + application/json: + schema: + $ref: '#/components/schemas/NewUserRequest' + required: true + responses: + 201: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + x-codegen-request-body-name: body + /user: + get: + tags: + - User and Authentication + summary: Get current user + description: Gets the currently logged-in user + operationId: GetCurrentUser + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + put: + tags: + - User and Authentication + summary: Update current user + description: Updated user information for current user + operationId: UpdateCurrentUser + requestBody: + description: User details to update. At least **one** field is required. + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRequest' + required: true + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + x-codegen-request-body-name: body + /profiles/{username}: + get: + tags: + - Profile + summary: Get a profile + description: Get a profile of a user of the system. Auth is optional + operationId: GetProfileByUsername + parameters: + - name: username + in: path + description: Username of the profile to get + required: true + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + /profiles/{username}/follow: + post: + tags: + - Profile + summary: Follow a user + description: Follow a user by username + operationId: FollowUserByUsername + parameters: + - name: username + in: path + description: Username of the profile you want to follow + required: true + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + delete: + tags: + - Profile + summary: Unfollow a user + description: Unfollow a user by username + operationId: UnfollowUserByUsername + parameters: + - name: username + in: path + description: Username of the profile you want to unfollow + required: true + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + /articles/feed: + get: + tags: + - Articles + summary: Get recent articles from users you follow + description: Get most recent articles from users you follow. Use query parameters + to limit. Auth is required + operationId: GetArticlesFeed + parameters: + - name: limit + in: query + description: Limit number of articles returned (default is 20) + schema: + type: integer + default: 20 + - name: offset + in: query + description: Offset/skip number of articles (default is 0) + schema: + type: integer + default: 0 + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MultipleArticlesResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + /articles: + get: + tags: + - Articles + summary: Get recent articles globally + description: Get most recent articles globally. Use query parameters to filter + results. Auth is optional + operationId: GetArticles + parameters: + - name: tag + in: query + description: Filter by tag + schema: + type: string + - name: author + in: query + description: Filter by author (username) + schema: + type: string + - name: favorited + in: query + description: Filter by favorites of a user (username) + schema: + type: string + - name: limit + in: query + description: Limit number of articles returned (default is 20) + schema: + type: integer + default: 20 + - name: offset + in: query + description: Offset/skip number of articles (default is 0) + schema: + type: integer + default: 0 + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MultipleArticlesResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + post: + tags: + - Articles + summary: Create an article + description: Create an article. Auth is required + operationId: CreateArticle + requestBody: + description: Article to create + content: + application/json: + schema: + $ref: '#/components/schemas/NewArticleRequest' + required: true + responses: + 201: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SingleArticleResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + x-codegen-request-body-name: article + /articles/{slug}: + get: + tags: + - Articles + summary: Get an article + description: Get an article. Auth not required + operationId: GetArticle + parameters: + - name: slug + in: path + description: Slug of the article to get + required: true + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SingleArticleResponse' + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + put: + tags: + - Articles + summary: Update an article + description: Update an article. Auth is required + operationId: UpdateArticle + parameters: + - name: slug + in: path + description: Slug of the article to update + required: true + schema: + type: string + requestBody: + description: Article to update + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateArticleRequest' + required: true + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SingleArticleResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + x-codegen-request-body-name: article + delete: + tags: + - Articles + summary: Delete an article + description: Delete an article. Auth is required + operationId: DeleteArticle + parameters: + - name: slug + in: path + description: Slug of the article to delete + required: true + schema: + type: string + responses: + 200: + description: OK + content: {} + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + /articles/{slug}/comments: + get: + tags: + - Comments + summary: Get comments for an article + description: Get the comments for an article. Auth is optional + operationId: GetArticleComments + parameters: + - name: slug + in: path + description: Slug of the article that you want to get comments for + required: true + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MultipleCommentsResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + post: + tags: + - Comments + summary: Create a comment for an article + description: Create a comment for an article. Auth is required + operationId: CreateArticleComment + parameters: + - name: slug + in: path + description: Slug of the article that you want to create a comment for + required: true + schema: + type: string + requestBody: + description: Comment you want to create + content: + application/json: + schema: + $ref: '#/components/schemas/NewCommentRequest' + required: true + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SingleCommentResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + x-codegen-request-body-name: comment + /articles/{slug}/comments/{id}: + delete: + tags: + - Comments + summary: Delete a comment for an article + description: Delete a comment for an article. Auth is required + operationId: DeleteArticleComment + parameters: + - name: slug + in: path + description: Slug of the article that you want to delete a comment for + required: true + schema: + type: string + - name: id + in: path + description: ID of the comment you want to delete + required: true + schema: + type: integer + responses: + 200: + description: OK + content: {} + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + /articles/{slug}/favorite: + post: + tags: + - Favorites + summary: Favorite an article + description: Favorite an article. Auth is required + operationId: CreateArticleFavorite + parameters: + - name: slug + in: path + description: Slug of the article that you want to favorite + required: true + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SingleArticleResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + delete: + tags: + - Favorites + summary: Unfavorite an article + description: Unfavorite an article. Auth is required + operationId: DeleteArticleFavorite + parameters: + - name: slug + in: path + description: Slug of the article that you want to unfavorite + required: true + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SingleArticleResponse' + 401: + description: Unauthorized + content: {} + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' + security: + - Token: [] + /tags: + get: + summary: Get tags + description: Get tags. Auth not required + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TagsResponse' + 422: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/GenericErrorModel' +components: + schemas: + LoginUser: + required: + - email + - password + type: object + properties: + email: + type: string + password: + type: string + format: password + LoginUserRequest: + required: + - user + type: object + properties: + user: + $ref: '#/components/schemas/LoginUser' + NewUser: + required: + - email + - password + - username + type: object + properties: + username: + type: string + email: + type: string + password: + type: string + format: password + NewUserRequest: + required: + - user + type: object + properties: + user: + $ref: '#/components/schemas/NewUser' + User: + required: + - bio + - email + - image + - token + - username + type: object + properties: + email: + type: string + token: + type: string + username: + type: string + bio: + type: string + image: + type: string + UserResponse: + required: + - user + type: object + properties: + user: + $ref: '#/components/schemas/User' + UpdateUser: + type: object + properties: + email: + type: string + token: + type: string + username: + type: string + bio: + type: string + image: + type: string + UpdateUserRequest: + required: + - user + type: object + properties: + user: + $ref: '#/components/schemas/UpdateUser' + ProfileResponse: + required: + - profile + type: object + properties: + profile: + $ref: '#/components/schemas/Profile' + Profile: + required: + - bio + - following + - image + - username + type: object + properties: + username: + type: string + bio: + type: string + image: + type: string + following: + type: boolean + Article: + required: + - author + - body + - createdAt + - description + - favorited + - favoritesCount + - slug + - tagList + - title + - updatedAt + type: object + properties: + slug: + type: string + title: + type: string + description: + type: string + body: + type: string + tagList: + type: array + items: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + favorited: + type: boolean + favoritesCount: + type: integer + author: + $ref: '#/components/schemas/Profile' + SingleArticleResponse: + required: + - article + type: object + properties: + article: + $ref: '#/components/schemas/Article' + MultipleArticlesResponse: + required: + - articles + - articlesCount + type: object + properties: + articles: + type: array + items: + $ref: '#/components/schemas/Article' + articlesCount: + type: integer + NewArticle: + required: + - body + - description + - title + type: object + properties: + title: + type: string + description: + type: string + body: + type: string + tagList: + type: array + items: + type: string + NewArticleRequest: + required: + - article + type: object + properties: + article: + $ref: '#/components/schemas/NewArticle' + UpdateArticle: + type: object + properties: + title: + type: string + description: + type: string + body: + type: string + UpdateArticleRequest: + required: + - article + type: object + properties: + article: + $ref: '#/components/schemas/UpdateArticle' + Comment: + required: + - author + - body + - createdAt + - id + - updatedAt + type: object + properties: + id: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + body: + type: string + author: + $ref: '#/components/schemas/Profile' + SingleCommentResponse: + required: + - comment + type: object + properties: + comment: + $ref: '#/components/schemas/Comment' + MultipleCommentsResponse: + required: + - comments + type: object + properties: + comments: + type: array + items: + $ref: '#/components/schemas/Comment' + NewComment: + required: + - body + type: object + properties: + body: + type: string + NewCommentRequest: + required: + - comment + type: object + properties: + comment: + $ref: '#/components/schemas/NewComment' + TagsResponse: + required: + - tags + type: object + properties: + tags: + type: array + items: + type: string + GenericErrorModel: + required: + - errors + type: object + properties: + errors: + required: + - body + type: object + properties: + body: + type: array + items: + type: string + securitySchemes: + Token: + type: apiKey + description: "For accessing the protected API resources, you must have received\ + \ a a valid JWT token after registering or logging in. This JWT token must\ + \ then be used for all protected resources by passing it in via the 'Authorization'\ + \ header.\n\nA JWT token is generated by the API by either registering via\ + \ /users or logging in via /users/login.\n\nThe following format must be in\ + \ the 'Authorization' header :\n\n Token xxxxxx.yyyyyyy.zzzzzz\n \n" + name: Authorization + in: header diff --git a/package.yaml b/package.yaml index c3cfe2a..269d9f9 100644 --- a/package.yaml +++ b/package.yaml @@ -39,9 +39,35 @@ library: source-dirs: src executables: - okapi-exe: + realworld-exe: main: Main.hs - source-dirs: app + source-dirs: examples/realworld + ghc-options: + - -threaded + - -rtsopts + - -with-rtsopts=-N + dependencies: + - okapi + - containers + - hasql + - hasql-th + - jwt + - parser-combinators + - time + - vector + - profunctors + calc-exe: + main: Main.hs + source-dirs: examples/calc + ghc-options: + - -threaded + - -rtsopts + - -with-rtsopts=-N + dependencies: + - okapi + todo-exe: + main: Main.hs + source-dirs: examples/todo ghc-options: - -threaded - -rtsopts diff --git a/run-api-tests.sh b/run-api-tests.sh new file mode 100755 index 0000000..c546e88 --- /dev/null +++ b/run-api-tests.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -x + +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" + +APIURL=${APIURL:-https://api.realworld.io/api} +USERNAME=${USERNAME:-u`date +%s`} +EMAIL=${EMAIL:-$USERNAME@mail.com} +PASSWORD=${PASSWORD:-password} + +npx newman run $SCRIPTDIR/Conduit.postman_collection.json \ + --delay-request 500 \ + --global-var "APIURL=$APIURL" \ + --global-var "USERNAME=$USERNAME" \ + --global-var "EMAIL=$EMAIL" \ + --global-var "PASSWORD=$PASSWORD" \ + "$@"