Introduce JSON Schema for API responses and validate the responses against it

This commit is contained in:
Jonne Haß 2019-04-27 16:01:54 +02:00
parent f7a27f0c07
commit 9b8f10358a
16 changed files with 555 additions and 15 deletions

414
lib/schemas/api_v1.json Normal file
View file

@ -0,0 +1,414 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "https://diaspora.software/api/v1/schema.json",
"oneOf": [
{"$ref": "#/definitions/aspects"},
{"$ref": "#/definitions/aspect"},
{"$ref": "#/definitions/comments_or_messages"},
{"$ref": "#/definitions/users"},
{"$ref": "#/definitions/conversations"},
{"$ref": "#/definitions/conversation"},
{"$ref": "#/definitions/authored_content_references"},
{"$ref": "#/definitions/likes"},
{"$ref": "#/definitions/notifications"},
{"$ref": "#/definitions/notification"},
{"$ref": "#/definitions/photos"},
{"$ref": "#/definitions/photo"},
{"$ref": "#/definitions/post"},
{"$ref": "#/definitions/posts"},
{"$ref": "#/definitions/tags"},
{"$ref": "#/definitions/own_user"},
{"$ref": "#/definitions/user"}
],
"definitions": {
"guid": {
"type": "string",
"minLength": 16,
"maxLength": 255
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"short_profile": {
"type": "object",
"properties": {
"guid": { "$ref": "#/definitions/guid" },
"diaspora_id": { "type": "string" },
"name": { "type": "string" },
"avatar": { "type": "string" }
},
"required": ["guid", "diaspora_id", "name"],
"additionalProperties": false
},
"aspects": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"order": {
"type": "integer"
}
},
"required": ["id", "name", "order"],
"additionalProperties": false
}
},
"aspect": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"order": {
"type": "integer"
},
"chat_enabled": {
"type": "boolean"
}
},
"required": ["id", "name", "order", "chat_enabled"],
"additionalProperties": false
},
"comments_or_messages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"guid": { "$ref": "#/definitions/guid" },
"created_at": { "$ref": "#/definitions/timestamp" },
"author": { "$ref": "#/definitions/short_profile" },
"body": { "type": "string" }
},
"required": ["guid", "created_at", "author", "body"],
"additionalProperties": false
}
},
"users": {
"type": "array",
"items": { "$ref": "#/definitions/short_profile" }
},
"conversations": {
"type": "array",
"items": { "$ref": "#/definitions/conversation" }
},
"conversation": {
"type": "object",
"properties": {
"guid": { "$ref": "#/definitions/guid" },
"subject": { "type": "string" },
"created_at": { "$ref": "#/definitions/timestamp" },
"read": { "type": "boolean" },
"participants": {
"type": "array",
"items": { "$ref": "#/definitions/short_profile" }
}
},
"required": ["subject", "created_at", "read", "participants"],
"additionalProperties": false
},
"authored_content_reference": {
"type": "object",
"created_at": { "$ref": "#/definitions/timestamp" },
"properties": {
"guid": { "$ref": "#/definitions/guid" },
"author": { "$ref": "#/definitions/short_profile" }
},
"required": ["guid", "created_at", "author"],
"additionalProperties": false
},
"authored_content_references": {
"type": "array",
"items": { "$ref": "#/definitions/authored_content_reference" }
},
"likes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"guid": { "$ref": "#/definitions/guid" },
"author": { "$ref": "#/definitions/short_profile" }
},
"required": ["guid", "author"],
"additionalProperties": false
}
},
"notifications": {
"type": "array",
"items": { "$ref": "#/definitions/notification" }
},
"notification": {
"type": "object",
"properties": {
"guid": { "$ref": "#/definitions/guid" },
"type": {
"enum": [
"also_commented",
"comment_on_post",
"liked",
"mentioned",
"mentioned_in_comment",
"reshared",
"started_sharing",
"contacts_birthday"
]
},
"read": { "type": "boolean" },
"created_at": { "$ref": "#/definitions/timestamp" },
"target": {
"type": "object",
"properties": {
"guid": { "$ref": "#/definitions/guid" },
"author": { "$ref": "#/definitions/short_profile" }
},
"required": ["guid"]
},
"event_creators": {
"type": "array",
"items": { "$ref": "#/definitions/short_profile" }
}
},
"required": ["guid", "type", "read", "created_at", "target"],
"additionalProperties": false
},
"photos": {
"type": "array",
"items": { "$ref": "#/definitions/photo"}
},
"photo_sizes": {
"type": "object",
"properties": {
"large": { "type": "string" },
"medium": { "type": "string" },
"small": { "type": "string" }
},
"required": ["large", "medium", "small"],
"additionalProperties": true
},
"photo_dimensions": {
"type": "object",
"properties": {
"width": { "type": "integer" },
"height": { "type": "integer" }
},
"required": ["width", "height"]
},
"photo": {
"type": "object",
"properties": {
"guid": { "$ref": "#/definitions/guid" },
"post": { "$ref": "#/definitions/guid" },
"created_at": { "$ref": "#/definitions/timestamp" },
"dimensions": { "$ref": "#/definitions/photo_dimensions" },
"sizes": { "$ref": "#/definitions/photo_sizes" }
},
"required": ["guid", "created_at", "dimensions", "sizes"],
"additionalProperties": false
},
"post_common": {
"type": "object",
"properties": {
"guid": { "$ref": "#/definitions/guid" },
"created_at": { "$ref": "#/definitions/timestamp" },
"title": { "type": "string" },
"body": { "type": "string" },
"provider_display_name": { "type": "string" },
"public": { "type": "boolean" },
"nsfw": { "type": "boolean" },
"author": { "$ref": "#/definitions/short_profile" },
"interaction_counters": {
"type": "object",
"properties": {
"comments": { "type": "integer" },
"likes": { "type": "integer" },
"reshares":{ "type": "integer" }
},
"required": ["comments", "likes", "reshares"],
"additionalProperties": false
},
"mentioned_people": {
"type": "array",
"items": { "$ref": "#/definitions/short_profile" }
},
"photos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"dimensions": { "$ref": "#/definitions/photo_dimensions" },
"sizes": { "$ref": "#/definitions/photo_sizes" }
},
"required": ["dimensions", "sizes"]
}
},
"poll": {
"type": "object",
"properties": {
"guid": { "$ref": "#/definitions/guid" },
"participation_count": { "type": "integer" },
"already_participated": { "type": "boolean" },
"question": { "type": "string" },
"poll_answers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"answer": { "type": "string" },
"vote_count": { "type": "integer" }
},
"required": ["id", "answer", "vote_count"],
"additionalProperties": false
}
}
},
"required": ["guid", "participation_count", "already_participated", "question", "poll_answers"],
"additionalProperties": false
},
"location": {
"type": "object",
"properties": {
"address": { "type": "string" },
"lat": { "type": "number" },
"lng": { "type": "number" }
},
"required": ["address", "lat", "lng"],
"additionalProperties": false
}
},
"required": ["guid", "created_at", "title", "body", "public", "nsfw", "author", "interaction_counters", "mentioned_people", "photos"]
},
"post": {
"anyOf": [
{
"allOf": [
{ "$ref": "#/definitions/post_common" },
{
"properties": {
"post_type": { "type": "string", "format": "^StatusMessage$" }
},
"required": ["post_type"]
}
]
},
{
"allOf": [
{ "$ref": "#/definitions/post_common" },
{
"properties": {
"post_type": { "type": "string", "format": "^Reshare$" },
"root": { "$ref": "#/definitions/authored_content_reference" }
},
"required": ["post_type", "root"]
}
]
}
]
},
"posts": {
"type": "array",
"items": { "$ref": "#/definitions/post" }
},
"tags": {
"type": "array",
"items": { "type": "string", "pattern": "^[^#]" }
},
"birthday": { "type": "string", "pattern": "^\\d\\d\\d\\d-\\d\\d-\\d\\d$" },
"user_data": {
"type": "object",
"properties": {
"guid": { "$ref": "#/definitions/guid" },
"diaspora_id": { "type": "string" },
"name": { "type": "string" },
"birthday": { "$ref": "#/definitions/birthday" },
"gender": { "type": "string" },
"location": { "type": "string" },
"bio": { "type": "string" },
"avatar": { "$ref": "#/definitions/photo_sizes" },
"tags": { "$ref": "#/definitions/tags" }
},
"required": ["guid", "diaspora_id", "tags"]
},
"own_user": {
"allOf": [
{ "$ref": "#/definitions/user_data" },
{
"type": "object",
"properties": {
"searchable": { "type": "boolean" },
"show_profile_info": { "type": "boolean" }
},
"required": ["searchable", "show_profile_info"]
}
]
},
"user": {
"allOf": [
{ "$ref": "#/definitions/user_data" },
{
"type": "object",
"properties": {
"blocked": { "type": "boolean" },
"relationship": {
"type": "object",
"properties": {
"receiving": { "type": "boolean" },
"sharing": { "type": "boolean" }
},
"required": ["receiving", "sharing"],
"additionalProperties": false
},
"aspects": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" }
},
"required": ["id", "name"],
"additionalProperties": false
}
}
},
"required": ["blocked", "relationship", "aspects"]
}
]
}
}
}

View file

@ -45,6 +45,8 @@ describe Api::V1::AspectsController do
expect(aspect["name"]).to eq(found_aspect.name)
expect(aspect["order"]).to eq(found_aspect.order_id)
end
expect(aspects.to_json).to match_json_schema(:api_v1_schema)
end
context "without impromper credentials" do
@ -79,6 +81,8 @@ describe Api::V1::AspectsController do
expect(aspect["name"]).to eq(@aspect2.name)
expect(aspect["order"]).to eq(@aspect2.order_id)
expect(aspect["chat_enabled"]).to eq(@aspect2.chat_enabled)
expect(aspect.to_json).to match_json_schema(:api_v1_schema)
end
end

View file

@ -139,6 +139,8 @@ describe Api::V1::CommentsController do
expect(comments.length).to eq(2)
confirm_comment_format(comments[0], auth.user, @comment_text1)
confirm_comment_format(comments[1], auth.user, @comment_text2)
expect(comments.to_json).to match_json_schema(:api_v1_schema)
end
end

View file

@ -56,6 +56,8 @@ describe Api::V1::ContactsController do
expect(response.status).to eq(200)
contacts = response_body_data(response)
expect(contacts.length).to eq(@aspect1.contacts.length)
expect(contacts.to_json).to match_json_schema(:api_v1_schema)
end
end

View file

@ -166,6 +166,8 @@ describe Api::V1::ConversationsController do
expect(returned_conversations.length).to eq(3)
actual_conversation = returned_conversations.select {|c| c["guid"] == @read_conversation_guid }[0]
confirm_conversation_format(actual_conversation, @read_conversation, [auth.user, alice])
expect(returned_conversations.to_json).to match_json_schema(:api_v1_schema)
end
it "returns all the user unread conversations" do
@ -221,6 +223,8 @@ describe Api::V1::ConversationsController do
expect(response.status).to eq(200)
conversation = response_body(response)
confirm_conversation_format(conversation, @conversation, [auth.user, alice])
expect(conversation.to_json).to match_json_schema(:api_v1_schema)
end
end

View file

@ -70,6 +70,8 @@ describe Api::V1::LikesController do
confirm_like_format(likes, alice)
confirm_like_format(likes, bob)
confirm_like_format(likes, auth.user)
expect(likes.to_json).to match_json_schema(:api_v1_schema)
end
end

View file

@ -37,7 +37,7 @@ describe Api::V1::MessagesController do
@message_text = "reply to first message"
end
describe "#create " do
describe "#create" do
before do
post api_v1_conversations_path, params: @conversation
@conversation_guid = JSON.parse(response.body)["guid"]
@ -114,7 +114,7 @@ describe Api::V1::MessagesController do
end
end
describe "#index " do
describe "#index" do
before do
post api_v1_conversations_path, params: @conversation
@conversation_guid = JSON.parse(response.body)["guid"]
@ -130,6 +130,8 @@ describe Api::V1::MessagesController do
messages = response_body_data(response)
expect(messages.length).to eq(1)
expect(messages.to_json).to match_json_schema(:api_v1_schema)
confirm_message_format(messages[0], "first message", auth.user)
conversation = get_conversation(@conversation_guid)
expect(conversation[:read]).to be_truthy

View file

@ -37,9 +37,11 @@ describe Api::V1::NotificationsController do
params: {access_token: access_token}
)
expect(response.status).to eq(200)
notification = response_body_data(response)
expect(notification.length).to eq(2)
confirm_notification_format(notification[1], @notification, "also_commented", nil)
notifications = response_body_data(response)
expect(notifications.length).to eq(2)
confirm_notification_format(notifications[1], @notification, "also_commented", nil)
expect(notifications.to_json).to match_json_schema(:api_v1_schema)
end
it "with proper credentials and unread only" do
@ -117,6 +119,8 @@ describe Api::V1::NotificationsController do
expect(response.status).to eq(200)
notification = JSON.parse(response.body)
confirm_notification_format(notification, @notification, "also_commented", @post)
expect(notification.to_json).to match_json_schema(:api_v1_schema)
end
end

View file

@ -76,7 +76,9 @@ describe Api::V1::PhotosController do
expect(response.status).to eq(200)
photo = response_body(response)
expect(photo.has_key?("post")).to be_falsey
confirm_photo_format(photo, @user_photo1, auth.user)
confirm_photo_format(photo, @user_photo1)
expect(photo.to_json).to match_json_schema(:api_v1_schema)
end
it "with correct GUID user's photo used in post and access token" do
@ -87,7 +89,7 @@ describe Api::V1::PhotosController do
expect(response.status).to eq(200)
photo = response_body(response)
expect(photo.has_key?("post")).to be_truthy
confirm_photo_format(photo, @user_photo2, auth.user)
confirm_photo_format(photo, @user_photo2)
end
it "with correct GUID of other user's public photo and access token" do
@ -97,7 +99,7 @@ describe Api::V1::PhotosController do
)
expect(response.status).to eq(200)
photo = response_body(response)
confirm_photo_format(photo, @alice_public_photo, alice)
confirm_photo_format(photo, @alice_public_photo)
end
end
@ -149,6 +151,8 @@ describe Api::V1::PhotosController do
expect(response.status).to eq(200)
photos = response_body_data(response)
expect(photos.length).to eq(2)
expect(photos.to_json).to match_json_schema(:api_v1_schema)
end
end
@ -198,7 +202,7 @@ describe Api::V1::PhotosController do
photo = response_body(response)
ref_photo = auth.user.photos.reload.find_by(guid: photo["guid"])
expect(ref_photo.pending).to be_falsey
confirm_photo_format(photo, ref_photo, auth.user)
confirm_photo_format(photo, ref_photo)
end
it "with valid encoded file set as pending" do
@ -363,7 +367,7 @@ describe Api::V1::PhotosController do
end
# rubocop:disable Metrics/AbcSize
def confirm_photo_format(photo, ref_photo, ref_user)
def confirm_photo_format(photo, ref_photo)
expect(photo["guid"]).to eq(ref_photo.guid)
if ref_photo.status_message_guid
expect(photo["post"]).to eq(ref_photo.status_message_guid)

View file

@ -68,6 +68,8 @@ describe Api::V1::PostsController do
expect(response.status).to eq(200)
post = response_body(response)
confirm_post_format(post, alice, @status, [bob, eve])
expect(post.to_json).to match_json_schema(:api_v1_schema)
end
end
@ -90,6 +92,8 @@ describe Api::V1::PostsController do
expect(response.status).to eq(200)
post = response_body(response)
confirm_post_format(post, alice, status_message)
expect(post.to_json).to match_json_schema(:api_v1_schema)
end
end
@ -105,6 +109,8 @@ describe Api::V1::PostsController do
expect(response.status).to eq(200)
post = response_body(response)
confirm_reshare_format(post, @status, alice)
expect(post.to_json).to match_json_schema(:api_v1_schema)
end
end

View file

@ -52,6 +52,8 @@ describe Api::V1::ResharesController do
reshare = reshares[0]
expect(reshare["guid"]).not_to be_nil
confirm_person_format(reshare["author"], alice)
expect(reshares.to_json).to match_json_schema(:api_v1_schema)
end
it "succeeds but empty with private post it can see" do

View file

@ -62,6 +62,8 @@ describe Api::V1::SearchController do
expect(response.status).to eq(200)
users = response_body_data(response)
expect(users.length).to eq(15)
expect(users.to_json).to match_json_schema(:api_v1_schema)
end
it "succeeds by name" do
@ -72,6 +74,8 @@ describe Api::V1::SearchController do
expect(response.status).to eq(200)
users = response_body_data(response)
expect(users.length).to eq(1)
expect(users.to_json).to match_json_schema(:api_v1_schema)
end
it "succeeds by handle" do
@ -82,6 +86,8 @@ describe Api::V1::SearchController do
expect(response.status).to eq(200)
users = response_body_data(response)
expect(users.length).to eq(1)
expect(users.to_json).to match_json_schema(:api_v1_schema)
end
it "doesn't return closed accounts" do
@ -177,6 +183,8 @@ describe Api::V1::SearchController do
expect(response.status).to eq(200)
posts = response_body_data(response)
expect(posts.length).to eq(2)
expect(posts.to_json).to match_json_schema(:api_v1_schema)
end
it "only returns public posts without private scope" do

View file

@ -72,15 +72,27 @@ describe Api::V1::StreamsController do
end
describe "#aspect" do
it "returns a valid schema" do
get(
api_v1_aspects_stream_path(aspect_ids: JSON.generate([@aspect.id])),
params: {access_token: access_token_read_only}
)
expect(response.status).to eq(200)
posts = response_body_data(response)
expect(posts.to_json).to match_json_schema(:api_v1_schema)
end
it "contains expected aspect message" do
get(
api_v1_aspects_stream_path(aspect_ids: JSON.generate([@aspect.id])),
params: {access_token: access_token_read_only}
)
expect(response.status).to eq(200)
post = response_body_data(response)
expect(post.length).to eq 3
json_post = post.select {|p| p["guid"] == @status.guid }.first
posts = response_body_data(response)
expect(posts.length).to eq 3
json_post = posts.select {|p| p["guid"] == @status.guid }.first
confirm_post_format(json_post, auth_read_only.user, @status)
end
@ -128,8 +140,10 @@ describe Api::V1::StreamsController do
params: {access_token: access_token_read_only}
)
expect(response.status).to eq(200)
post = response_body_data(response)
expect(post.length).to eq(3)
posts = response_body_data(response)
expect(posts.length).to eq(3)
expect(posts.to_json).to match_json_schema(:api_v1_schema)
end
it "public posts only tags expected" do
@ -160,6 +174,17 @@ describe Api::V1::StreamsController do
end
describe "#activity" do
it "returns a valid schema" do
get(
api_v1_activity_stream_path,
params: {access_token: access_token_read_only}
)
expect(response.status).to eq(200)
posts = response_body_data(response)
expect(posts.to_json).to match_json_schema(:api_v1_schema)
end
it "contains activity message" do
get(
api_v1_activity_stream_path,
@ -192,6 +217,17 @@ describe Api::V1::StreamsController do
end
describe "#main" do
it "returns a valid schema" do
get(
api_v1_stream_path,
params: {access_token: access_token_read_only}
)
expect(response.status).to eq(200)
posts = response_body_data(response)
expect(posts.to_json).to match_json_schema(:api_v1_schema)
end
it "contains main message" do
get(
api_v1_stream_path,
@ -224,6 +260,17 @@ describe Api::V1::StreamsController do
end
describe "#commented" do
it "returns a valid schema" do
get(
api_v1_commented_stream_path,
params: {access_token: access_token_read_only}
)
expect(response.status).to eq(200)
posts = response_body_data(response)
expect(posts.to_json).to match_json_schema(:api_v1_schema)
end
it "contains commented message" do
get(
api_v1_commented_stream_path,
@ -254,6 +301,17 @@ describe Api::V1::StreamsController do
end
describe "#mentions" do
it "returns a valid schema" do
get(
api_v1_mentions_stream_path,
params: {access_token: access_token_read_only}
)
expect(response.status).to eq(200)
posts = response_body_data(response)
expect(posts.to_json).to match_json_schema(:api_v1_schema)
end
it "contains mentions message" do
get(
api_v1_mentions_stream_path,
@ -284,6 +342,17 @@ describe Api::V1::StreamsController do
end
describe "#liked" do
it "returns a valid schema" do
get(
api_v1_liked_stream_path,
params: {access_token: access_token_read_only}
)
expect(response.status).to eq(200)
posts = response_body_data(response)
expect(posts.to_json).to match_json_schema(:api_v1_schema)
end
it "contains liked message" do
get(
api_v1_liked_stream_path,

View file

@ -103,6 +103,8 @@ describe Api::V1::TagFollowingsController do
items = JSON.parse(response.body)
expect(items.length).to eq(@expected_tags.length)
@expected_tags.each {|tag| expect(items.find(tag)).to be_truthy }
expect(items.to_json).to match_json_schema(:api_v1_schema)
end
end

View file

@ -58,6 +58,8 @@ describe Api::V1::UsersController do
expect(response.status).to eq(200)
expect(user["guid"]).to eq(auth.user.guid)
confirm_self_data_format(user)
expect(user.to_json).to match_json_schema(:api_v1_schema)
end
it "fails if invalid token" do
@ -81,6 +83,8 @@ describe Api::V1::UsersController do
expect(response.status).to eq(200)
expect(user["guid"]).to eq(alice.person.guid)
confirm_public_profile_hash(user)
expect(user.to_json).to match_json_schema(:api_v1_schema)
end
it "succeeds with in Aspect valid user" do
@ -96,6 +100,8 @@ describe Api::V1::UsersController do
expect(response.status).to eq(200)
expect(user["guid"]).to eq(alice.person.guid)
confirm_public_profile_hash(user)
expect(user.to_json).to match_json_schema(:api_v1_schema)
end
it "succeeds with limited data on non-public/not shared" do
@ -120,6 +126,8 @@ describe Api::V1::UsersController do
expect(response.status).to eq(200)
expect(user["guid"]).to eq(eve.person.guid)
confirm_public_profile_hash(user)
expect(user.to_json).to match_json_schema(:api_v1_schema)
end
it "fails if invalid token" do
@ -315,6 +323,8 @@ describe Api::V1::UsersController do
contacts = response_body_data(response)
expect(contacts.length).to eq(1)
confirm_person_format(contacts[0], alice)
expect(contacts.to_json).to match_json_schema(:api_v1_schema)
end
it "fails with invalid GUID" do
@ -381,6 +391,8 @@ describe Api::V1::UsersController do
expect(guids).to include(@public_photo1.guid, @public_photo2.guid, @shared_photo1.guid)
expect(guids).not_to include(@private_photo1.guid)
confirm_photos(photos)
expect(photos.to_json).to match_json_schema(:api_v1_schema)
end
it "returns only public photos of other user without private:read scope in token" do
@ -456,6 +468,8 @@ describe Api::V1::UsersController do
expect(guids).not_to include(@private_post1.guid)
post = posts.select {|p| p["guid"] == @public_post1.guid }
confirm_post_format(post[0], alice, @public_post1)
expect(posts.to_json).to match_json_schema(:api_v1_schema)
end
it "returns logged in user's posts" do

View file

@ -146,6 +146,7 @@ RSpec.configure do |config|
config.include JSON::SchemaMatchers
config.json_schemas[:archive_schema] = "lib/schemas/archive-format.json"
config.json_schemas[:api_v1_schema] = "lib/schemas/api_v1.json"
JSON::Validator.add_schema(
JSON::Schema.new(