Merge branch 'develop' into storage_transactions

paul/schema_breaking_changes
Mark Haines 2014-08-27 16:54:12 +01:00
commit 1d95e78759
34 changed files with 1414 additions and 865 deletions

2
.gitignore vendored
View File

@ -10,7 +10,7 @@ docs/build/
*.egg-info
cmdclient_config.json
homeserver.db
homeserver*.db
.coverage
htmlcov

View File

@ -471,7 +471,7 @@ class SynapseCmd(cmd.Cmd):
room_name = args["vis"]
body["room_alias_name"] = room_name
reactor.callFromThread(self._run_and_pprint, "POST", "/rooms", body)
reactor.callFromThread(self._run_and_pprint, "POST", "/createRoom", body)
def do_raw(self, line):
"""Directly send a JSON object: "raw <method> <path> <data> <notoken>"

View File

@ -21,6 +21,14 @@
{
"path": "/presence",
"description": "Presence operations"
},
{
"path": "/events",
"description": "Event operations"
},
{
"path": "/directory",
"description": "Directory operations"
}
],
"authorizations": {

View File

@ -0,0 +1,83 @@
{
"apiVersion": "1.0.0",
"swaggerVersion": "1.2",
"basePath": "http://localhost:8080/matrix/client/api/v1",
"resourcePath": "/directory",
"produces": [
"application/json"
],
"apis": [
{
"path": "/directory/room/{roomAlias}",
"operations": [
{
"method": "GET",
"summary": "Get the room ID corresponding to this room alias.",
"type": "DirectoryResponse",
"nickname": "get_room_id_for_alias",
"parameters": [
{
"name": "roomAlias",
"description": "The room alias.",
"required": true,
"type": "string",
"paramType": "path"
}
]
},
{
"method": "PUT",
"summary": "Create a new mapping from room alias to room ID.",
"type": "void",
"nickname": "add_room_alias",
"parameters": [
{
"name": "roomAlias",
"description": "The room alias to set.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "body",
"description": "The room ID to set.",
"required": true,
"type": "RoomAliasRequest",
"paramType": "body"
}
]
}
]
}
],
"models": {
"DirectoryResponse": {
"id": "DirectoryResponse",
"properties": {
"room_id": {
"type": "string",
"description": "The fully-qualified room ID.",
"required": true
},
"servers": {
"type": "array",
"items": {
"$ref": "string"
},
"description": "A list of servers that know about this room.",
"required": true
}
}
},
"RoomAliasRequest": {
"id": "RoomAliasRequest",
"properties": {
"room_id": {
"type": "string",
"description": "The room ID to map the alias to.",
"required": true
}
}
}
}
}

View File

@ -1,299 +1,246 @@
{
"apiVersion": "1.0.0",
"swaggerVersion": "1.2",
"basePath": "http://petstore.swagger.wordnik.com/api",
"resourcePath": "/user",
"basePath": "http://localhost:8080/matrix/client/api/v1",
"resourcePath": "/events",
"produces": [
"application/json"
],
"apis": [
{
"path": "/user",
"operations": [
{
"method": "POST",
"summary": "Create user",
"notes": "This can only be done by the logged in user.",
"type": "void",
"nickname": "createUser",
"authorizations": {
"oauth2": [
{
"scope": "test:anything",
"description": "anything"
}
]
},
"parameters": [
{
"name": "body",
"description": "Created user object",
"required": true,
"type": "User",
"paramType": "body"
}
]
}
]
},
{
"path": "/user/logout",
"path": "/events",
"operations": [
{
"method": "GET",
"summary": "Logs out current logged in user session",
"notes": "",
"type": "void",
"nickname": "logoutUser",
"authorizations": {},
"parameters": []
"summary": "Listen on the event stream",
"notes": "This can only be done by the logged in user. This will block until an event is received, or until the timeout is reached.",
"type": "PaginationChunk",
"nickname": "get_event_stream"
}
]
},
{
"path": "/user/createWithArray",
"operations": [
],
"parameters": [
{
"method": "POST",
"summary": "Creates list of users with given input array",
"notes": "",
"type": "void",
"nickname": "createUsersWithArrayInput",
"authorizations": {
"oauth2": [
{
"scope": "test:anything",
"description": "anything"
}
]
},
"parameters": [
{
"name": "body",
"description": "List of user object",
"required": true,
"type": "array",
"items": {
"$ref": "User"
},
"paramType": "body"
}
]
}
]
},
{
"path": "/user/createWithList",
"operations": [
{
"method": "POST",
"summary": "Creates list of users with given list input",
"notes": "",
"type": "void",
"nickname": "createUsersWithListInput",
"authorizations": {
"oauth2": [
{
"scope": "test:anything",
"description": "anything"
}
]
},
"parameters": [
{
"name": "body",
"description": "List of user object",
"required": true,
"type": "array",
"items": {
"$ref": "User"
},
"paramType": "body"
}
]
}
]
},
{
"path": "/user/{username}",
"operations": [
{
"method": "PUT",
"summary": "Updated user",
"notes": "This can only be done by the logged in user.",
"type": "void",
"nickname": "updateUser",
"authorizations": {
"oauth2": [
{
"scope": "test:anything",
"description": "anything"
}
]
},
"parameters": [
{
"name": "username",
"description": "name that need to be deleted",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "body",
"description": "Updated user object",
"required": true,
"type": "User",
"paramType": "body"
}
],
"responseMessages": [
{
"code": 400,
"message": "Invalid username supplied"
},
{
"code": 404,
"message": "User not found"
}
]
},
{
"method": "DELETE",
"summary": "Delete user",
"notes": "This can only be done by the logged in user.",
"type": "void",
"nickname": "deleteUser",
"authorizations": {
"oauth2": [
{
"scope": "test:anything",
"description": "anything"
}
]
},
"parameters": [
{
"name": "username",
"description": "The name that needs to be deleted",
"required": true,
"type": "string",
"paramType": "path"
}
],
"responseMessages": [
{
"code": 400,
"message": "Invalid username supplied"
},
{
"code": 404,
"message": "User not found"
}
]
},
{
"method": "GET",
"summary": "Get user by user name",
"notes": "",
"type": "User",
"nickname": "getUserByName",
"authorizations": {},
"parameters": [
{
"name": "username",
"description": "The name that needs to be fetched. Use user1 for testing.",
"required": true,
"type": "string",
"paramType": "path"
}
],
"responseMessages": [
{
"code": 400,
"message": "Invalid username supplied"
},
{
"code": 404,
"message": "User not found"
}
]
}
]
},
{
"path": "/user/login",
"operations": [
{
"method": "GET",
"summary": "Logs user into the system",
"notes": "",
"name": "from",
"description": "The token to stream from.",
"required": false,
"type": "string",
"nickname": "loginUser",
"authorizations": {},
"paramType": "query"
},
{
"name": "timeout",
"description": "The maximum time in milliseconds to wait for an event.",
"required": false,
"type": "integer",
"paramType": "query"
}
],
"responseMessages": [
{
"code": 400,
"message": "Bad pagination token."
}
]
},
{
"path": "/events/{eventId}",
"operations": [
{
"method": "GET",
"summary": "Get information about a single event.",
"notes": "Get information about a single event.",
"type": "Event",
"nickname": "get_event",
"parameters": [
{
"name": "username",
"description": "The user name for login",
"name": "eventId",
"description": "The event ID to get.",
"required": true,
"type": "string",
"paramType": "query"
},
{
"name": "password",
"description": "The password for login in clear text",
"required": true,
"type": "string",
"paramType": "query"
"paramType": "path"
}
],
"responseMessages": [
{
"code": 400,
"message": "Invalid username and password combination"
"code": 404,
"message": "Event not found."
}
]
}
]
},
{
"path": "/initialSync",
"operations": [
{
"method": "GET",
"summary": "Get this user's current state.",
"notes": "Get this user's current state.",
"type": "InitialSyncResponse",
"nickname": "initial_sync",
"parameters": [
{
"name": "limit",
"description": "The maximum number of messages to return for each room.",
"type": "integer",
"paramType": "query",
"required": false
}
]
}
]
},
{
"path": "/publicRooms",
"operations": [
{
"method": "GET",
"summary": "Get a list of publicly visible rooms.",
"type": "PublicRoomsPaginationChunk",
"nickname": "get_public_room_list"
}
]
}
],
"models": {
"User": {
"id": "User",
"PaginationChunk": {
"id": "PaginationChunk",
"properties": {
"id": {
"type": "integer",
"format": "int64"
"start": {
"type": "string",
"description": "A token which correlates to the first value in \"chunk\" for paginating.",
"required": true
},
"firstName": {
"type": "string"
"end": {
"type": "string",
"description": "A token which correlates to the last value in \"chunk\" for paginating.",
"required": true
},
"username": {
"type": "string"
"chunk": {
"type": "array",
"description": "An array of events.",
"required": true,
"items": {
"$ref": "Event"
}
}
}
},
"Event": {
"id": "Event",
"properties": {
"event_id": {
"type": "string",
"description": "An ID which uniquely identifies this event.",
"required": true
},
"lastName": {
"type": "string"
"room_id": {
"type": "string",
"description": "The room in which this event occurred.",
"required": true
}
}
},
"PublicRoomInfo": {
"id": "PublicRoomInfo",
"properties": {
"aliases": {
"type": "array",
"description": "A list of room aliases for this room.",
"items": {
"$ref": "string"
}
},
"email": {
"type": "string"
"name": {
"type": "string",
"description": "The name of the room, as given by the m.room.name state event."
},
"password": {
"type": "string"
"room_id": {
"type": "string",
"description": "The room ID for this public room.",
"required": true
},
"phone": {
"type": "string"
"topic": {
"type": "string",
"description": "The topic of this room, as given by the m.room.topic state event."
}
}
},
"PublicRoomsPaginationChunk": {
"id": "PublicRoomsPaginationChunk",
"properties": {
"start": {
"type": "string",
"description": "A token which correlates to the first value in \"chunk\" for paginating.",
"required": true
},
"userStatus": {
"type": "integer",
"format": "int32",
"description": "User Status",
"enum": [
"1-registered",
"2-active",
"3-closed"
]
"end": {
"type": "string",
"description": "A token which correlates to the last value in \"chunk\" for paginating.",
"required": true
},
"chunk": {
"type": "array",
"description": "A list of public room data.",
"required": true,
"items": {
"$ref": "PublicRoomInfo"
}
}
}
},
"InitialSyncResponse": {
"id": "InitialSyncResponse",
"properties": {
"end": {
"type": "string",
"description": "A streaming token which can be used with /events to continue from this snapshot of data.",
"required": true
},
"presence": {
"type": "array",
"description": "A list of presence events.",
"items": {
"$ref": "Event"
},
"required": false
},
"rooms": {
"type": "array",
"description": "A list of initial sync room data.",
"required": false,
"items": {
"$ref": "InitialSyncRoomData"
}
}
}
},
"InitialSyncRoomData": {
"id": "InitialSyncRoomData",
"properties": {
"membership": {
"type": "string",
"description": "This user's membership state in this room.",
"required": true
},
"room_id": {
"type": "string",
"description": "The ID of this room.",
"required": true
},
"messages": {
"type": "PaginationChunk",
"description": "The most recent messages for this room, governed by the limit parameter.",
"required": false
},
"state": {
"type": "array",
"description": "A list of state events representing the current state of the room.",
"required": false,
"items": {
"$ref": "Event"
}
}
}
}
}
}
}

View File

@ -55,7 +55,7 @@
]
},
{
"path": "/presence_list/{userId}",
"path": "/presence/list/{userId}",
"operations": [
{
"method": "GET",

View File

@ -14,13 +14,103 @@
},
"apis": [
{
"path": "/rooms/{roomId}/messages/{userId}/{messageId}",
"path": "/rooms/{roomId}/send/{eventType}/{txnId}",
"operations": [
{
"method": "PUT",
"summary": "Send a generic non-state event to this room.",
"notes": "This operation can also be done as a POST to /rooms/{roomId}/send/{eventType}",
"type": "EventId",
"nickname": "send_non_state_event",
"consumes": [
"application/json"
],
"parameters": [
{
"name": "body",
"description": "The event contents",
"required": true,
"type": "EventContent",
"paramType": "body"
},
{
"name": "roomId",
"description": "The room to send the message in.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "eventType",
"description": "The type of event to send.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "txnId",
"description": "A client transaction ID to ensure idempotency. This can only be omitted if the HTTP method becomes a POST.",
"required": true,
"type": "string",
"paramType": "path"
}
]
}
]
},
{
"path": "/rooms/{roomId}/state/{eventType}/{stateKey}",
"operations": [
{
"method": "PUT",
"summary": "Send a generic state event to this room.",
"notes": "The state key can be omitted, such that you can PUT to /rooms/{roomId}/state/{eventType}. The state key defaults to a 0 length string in this case.",
"type": "void",
"nickname": "send_state_event",
"consumes": [
"application/json"
],
"parameters": [
{
"name": "body",
"description": "The event contents",
"required": true,
"type": "EventContent",
"paramType": "body"
},
{
"name": "roomId",
"description": "The room to send the message in.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "eventType",
"description": "The type of event to send.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "stateKey",
"description": "An identifier used to specify clobbering semantics. State events with the same (roomId, eventType, stateKey) will be replaced.",
"required": true,
"type": "string",
"paramType": "path"
}
]
}
]
},
{
"path": "/rooms/{roomId}/send/m.room.message/{txnId}",
"operations": [
{
"method": "PUT",
"summary": "Send a message in this room.",
"notes": "Send a message in this room.",
"type": "void",
"notes": "This operation can also be done as a POST to /rooms/{roomId}/send/m.room.message",
"type": "EventId",
"nickname": "send_message",
"consumes": [
"application/json"
@ -41,67 +131,18 @@
"paramType": "path"
},
{
"name": "userId",
"description": "The fully qualified message sender's user ID.",
"name": "txnId",
"description": "A client transaction ID to ensure idempotency. This can only be omitted if the HTTP method becomes a POST.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "messageId",
"description": "A message ID which is unique for each room and user.",
"required": true,
"type": "string",
"paramType": "path"
}
],
"responseMessages": [
{
"code": 403,
"message": "Must send messages as yourself."
}
]
},
{
"method": "GET",
"summary": "Get a message from this room.",
"notes": "Get a message from this room.",
"type": "Message",
"nickname": "get_message",
"parameters": [
{
"name": "roomId",
"description": "The room to send the message in.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "userId",
"description": "The fully qualified message sender's user ID.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "messageId",
"description": "A message ID which is unique for each room and user.",
"required": true,
"type": "string",
"paramType": "path"
}
],
"responseMessages": [
{
"code": 404,
"message": "Message not found."
}
]
}
]
},
{
"path": "/rooms/{roomId}/topic",
"path": "/rooms/{roomId}/state/m.room.topic",
"operations": [
{
"method": "PUT",
@ -127,12 +168,6 @@
"type": "string",
"paramType": "path"
}
],
"responseMessages": [
{
"code": 403,
"message": "Must send messages as yourself."
}
]
},
{
@ -160,13 +195,13 @@
]
},
{
"path": "/rooms/{roomId}/messages/{msgSenderId}/{messageId}/feedback/{senderId}/{feedbackType}",
"path": "/rooms/{roomId}/send/m.room.message.feedback/{txnId}",
"operations": [
{
"method": "PUT",
"summary": "Send feedback to a message.",
"notes": "Send feedback to a message.",
"type": "void",
"notes": "This operation can also be done as a POST to /rooms/{roomId}/send/m.room.message.feedback",
"type": "EventId",
"nickname": "send_feedback",
"consumes": [
"application/json"
@ -187,107 +222,124 @@
"paramType": "path"
},
{
"name": "msgSenderId",
"description": "The fully qualified message sender's user ID.",
"name": "txnId",
"description": "A client transaction ID to ensure idempotency. This can only be omitted if the HTTP method becomes a POST.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "messageId",
"description": "A message ID which is unique for each room and user.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "senderId",
"description": "The fully qualified feedback sender's user ID.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "feedbackType",
"description": "The type of feedback being sent.",
"required": true,
"type": "string",
"paramType": "path",
"enum": [
"d",
"r"
]
}
],
"responseMessages": [
{
"code": 403,
"message": "Must send feedback as yourself."
},
{
"code": 400,
"message": "Bad feedback type."
}
]
},
{
"method": "GET",
"summary": "Get feedback for a message.",
"notes": "Get feedback for a message.",
"type": "Feedback",
"nickname": "get_feedback",
"parameters": [
{
"name": "roomId",
"description": "The room to send the message in.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "msgSenderId",
"description": "The fully qualified message sender's user ID.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "messageId",
"description": "A message ID which is unique for each room and user.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "senderId",
"description": "The fully qualified feedback sender's user ID.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "feedbackType",
"description": "Enum: The type of feedback being sent.",
"required": true,
"type": "string",
"paramType": "path",
"enum": [
"d",
"r"
]
}
],
"responseMessages": [
{
"code": 404,
"message": "Feedback not found."
}
]
}
]
},
{
"path": "/rooms/{roomId}/members/{userId}/state",
"path": "/rooms/{roomId}/invite/{txnId}",
"operations": [
{
"method": "PUT",
"summary": "Invite a user to this room.",
"notes": "This operation can also be done as a POST to /rooms/{roomId}/invite",
"type": "void",
"nickname": "invite",
"consumes": [
"application/json"
],
"parameters": [
{
"name": "roomId",
"description": "The room which has this user.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "txnId",
"description": "A client transaction ID for this PUT to ensure idempotency. This can only be omitted if the HTTP method becomes a POST. ",
"required": false,
"type": "string",
"paramType": "path"
},
{
"name": "body",
"description": "The user to invite.",
"required": true,
"type": "InviteRequest",
"paramType": "body"
}
]
}
]
},
{
"path": "/rooms/{roomId}/join/{txnId}",
"operations": [
{
"method": "PUT",
"summary": "Join this room.",
"notes": "This operation can also be done as a POST to /rooms/{roomId}/join",
"type": "void",
"nickname": "join_room",
"consumes": [
"application/json"
],
"parameters": [
{
"name": "roomId",
"description": "The room to join.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "txnId",
"description": "A client transaction ID for this PUT to ensure idempotency. This can only be omitted if the HTTP method becomes a POST. ",
"required": false,
"type": "string",
"paramType": "path"
}
]
}
]
},
{
"path": "/rooms/{roomId}/leave/{txnId}",
"operations": [
{
"method": "PUT",
"summary": "Leave this room.",
"notes": "This operation can also be done as a POST to /rooms/{roomId}/leave",
"type": "void",
"nickname": "leave",
"consumes": [
"application/json"
],
"parameters": [
{
"name": "roomId",
"description": "The room to leave.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "txnId",
"description": "A client transaction ID for this PUT to ensure idempotency. This can only be omitted if the HTTP method becomes a POST. ",
"required": false,
"type": "string",
"paramType": "path"
}
]
}
]
},
{
"path": "/rooms/{roomId}/state/m.room.member/{userId}",
"operations": [
{
"method": "PUT",
@ -376,58 +428,25 @@
"message": "Member not found."
}
]
},
{
"method": "DELETE",
"summary": "Leave a room.",
"notes": "Leave a room.",
"type": "void",
"nickname": "remove_membership",
"parameters": [
{
"name": "userId",
"description": "The user who is leaving.",
"required": true,
"type": "string",
"paramType": "path"
},
{
"name": "roomId",
"description": "The room which has this user.",
"required": true,
"type": "string",
"paramType": "path"
}
],
"responseMessages": [
{
"code": 403,
"message": "You are not in the room."
},
{
"code": 403,
"message": "Cannot force another user to leave."
}
]
}
]
},
{
"path": "/join/{roomAlias}",
"path": "/join/{roomAliasOrId}",
"operations": [
{
"method": "PUT",
"summary": "Join a room via a room alias.",
"notes": "Join a room via a room alias.",
"summary": "Join a room via a room alias or room ID.",
"notes": "Join a room via a room alias or room ID.",
"type": "RoomInfo",
"nickname": "join_room_via_alias",
"nickname": "join",
"consumes": [
"application/json"
],
"parameters": [
{
"name": "roomAlias",
"description": "The room alias to join.",
"name": "roomAliasOrId",
"description": "The room alias or room ID to join.",
"required": true,
"type": "string",
"paramType": "path"
@ -443,7 +462,7 @@
]
},
{
"path": "/rooms",
"path": "/createRoom",
"operations": [
{
"method": "POST",
@ -477,7 +496,7 @@
]
},
{
"path": "/rooms/{roomId}/messages/list",
"path": "/rooms/{roomId}/messages",
"operations": [
{
"method": "GET",
@ -519,7 +538,7 @@
]
},
{
"path": "/rooms/{roomId}/members/list",
"path": "/rooms/{roomId}/members",
"operations": [
{
"method": "GET",
@ -704,12 +723,17 @@
"properties": {
"event_id": {
"type": "string",
"description": "An ID which uniquely identifies this event.",
"description": "An ID which uniquely identifies this event. This is automatically set by the server.",
"required": true
},
"room_id": {
"type": "string",
"description": "The room in which this event occurred.",
"description": "The room in which this event occurred. This is automatically set by the server.",
"required": true
},
"type": {
"type": "string",
"description": "The event type.",
"required": true
}
},
@ -717,6 +741,26 @@
"MessageEvent"
]
},
"EventId": {
"id": "EventId",
"properties": {
"event_id": {
"type": "string",
"description": "The allocated event ID for this event.",
"required": true
}
}
},
"EventContent": {
"id": "EventContent",
"properties": {
"__event_content_keys__": {
"type": "string",
"description": "Event-specific content keys and values.",
"required": false
}
}
},
"MessageEvent": {
"id": "MessageEvent",
"properties": {
@ -733,73 +777,12 @@
}
}
},
"Tag": {
"id": "Tag",
"InviteRequest": {
"id": "InviteRequest",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
}
}
},
"Pet": {
"id": "Pet",
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "integer",
"format": "int64",
"description": "unique identifier for the pet",
"minimum": "0.0",
"maximum": "100.0"
},
"category": {
"$ref": "Category"
},
"name": {
"type": "string"
},
"photoUrls": {
"type": "array",
"items": {
"type": "string"
}
},
"tags": {
"type": "array",
"items": {
"$ref": "Tag"
}
},
"status": {
"user_id": {
"type": "string",
"description": "pet status in the store",
"enum": [
"available",
"pending",
"sold"
]
}
}
},
"Category": {
"id": "Category",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"pet": {
"$ref": "Pet"
"description": "The fully-qualified user ID."
}
}
}

View File

@ -1,92 +0,0 @@
=========================
Client-Server URL Summary
=========================
A brief overview of the URL scheme involved in the Synapse Client-Server API.
URLs
====
Fetch events:
GET /events
Registering an account
POST /register
Unregistering an account
POST /unregister
Rooms
-----
Creating a room by ID
PUT /rooms/$roomid
Creating an anonymous room
POST /rooms
Room topic
GET /rooms/$roomid/topic
PUT /rooms/$roomid/topic
List rooms
GET /rooms/list
Invite/Join/Leave
GET /rooms/$roomid/members/$userid/state
PUT /rooms/$roomid/members/$userid/state
DELETE /rooms/$roomid/members/$userid/state
List members
GET /rooms/$roomid/members/list
Sending/reading messages
PUT /rooms/$roomid/messages/$sender/$msgid
Feedback
GET /rooms/$roomid/messages/$sender/$msgid/feedback/$feedbackuser/$feedback
PUT /rooms/$roomid/messages/$sender/$msgid/feedback/$feedbackuser/$feedback
Paginating messages
GET /rooms/$roomid/messages/list
Profiles
--------
Display name
GET /profile/$userid/displayname
PUT /profile/$userid/displayname
Avatar URL
GET /profile/$userid/avatar_url
PUT /profile/$userid/avatar_url
Metadata
GET /profile/$userid/metadata
POST /profile/$userid/metadata
Presence
--------
My state or status message
GET /presence/$userid/status
PUT /presence/$userid/status
also 'GET' for fetching others
TODO(paul): per-device idle time, device type; similar to above
My presence list
GET /presence_list/$myuserid
POST /presence_list/$myuserid
body is JSON-encoded dict of keys:
invite: list of UserID strings to invite
drop: list of UserID strings to remove
TODO(paul): define other ops: accept, group management, ordering?
Presence polling start/stop
POST /presence_list/$myuserid?op=start
POST /presence_list/$myuserid?op=stop
Presence invite
POST /presence_list/$myuserid/invite/$targetuserid

View File

@ -43,6 +43,22 @@ import re
logger = logging.getLogger(__name__)
SCHEMAS = [
"transactions",
"pdu",
"users",
"profiles",
"presence",
"im",
"room_aliases",
]
# Remember to update this number every time an incompatible change is made to
# database schema files, so the users will be informed on server restarts.
SCHEMA_VERSION = 1
class SynapseHomeServer(HomeServer):
def build_http_client(self):
@ -65,31 +81,39 @@ class SynapseHomeServer(HomeServer):
don't have to worry about overwriting existing content.
"""
logging.info("Preparing database: %s...", self.db_name)
with sqlite3.connect(self.db_name) as db_conn:
c = db_conn.cursor()
c.execute("PRAGMA user_version")
row = c.fetchone()
if row and row[0]:
user_version = row[0]
if user_version < SCHEMA_VERSION:
# TODO(paul): add some kind of intelligent fixup here
raise ValueError("Cannot use this database as the " +
"schema version (%d) does not match (%d)" %
(user_version, SCHEMA_VERSION)
)
else:
for sql_loc in SCHEMAS:
sql_script = read_schema(sql_loc)
c.executescript(sql_script)
db_conn.commit()
c.execute("PRAGMA user_version = %d" % SCHEMA_VERSION)
c.close()
logging.info("Database prepared in %s.", self.db_name)
pool = adbapi.ConnectionPool(
'sqlite3', self.db_name, check_same_thread=False,
cp_min=1, cp_max=1)
schemas = [
"transactions",
"pdu",
"users",
"profiles",
"presence",
"im",
"room_aliases",
]
for sql_loc in schemas:
sql_script = read_schema(sql_loc)
with sqlite3.connect(self.db_name) as db_conn:
c = db_conn.cursor()
c.executescript(sql_script)
c.close()
db_conn.commit()
logging.info("Database prepared in %s.", self.db_name)
return pool
def create_resource_tree(self, web_client, redirect_root_to_web_client):
@ -184,6 +208,7 @@ class SynapseHomeServer(HomeServer):
def start_listening(self, port):
reactor.listenTCP(port, Site(self.root_resource))
logger.info("Synapse now listening on port %d", port)
def setup_logging(verbosity=0, filename=None, config_path=None):
@ -278,7 +303,7 @@ def setup():
redirect_root_to_web_client=True)
hs.start_listening(args.port)
hs.build_db_pool()
hs.get_db_pool()
if args.manhole:
f = twisted.manhole.telnet.ShellFactory()

View File

@ -23,6 +23,7 @@ from .login import LoginHandler
from .profile import ProfileHandler
from .presence import PresenceHandler
from .directory import DirectoryHandler
from .typing import TypingNotificationHandler
class Handlers(object):
@ -46,3 +47,4 @@ class Handlers(object):
self.room_list_handler = RoomListHandler(hs)
self.login_handler = LoginHandler(hs)
self.directory_handler = DirectoryHandler(hs)
self.typing_notification_handler = TypingNotificationHandler(hs)

146
synapse/handlers/typing.py Normal file
View File

@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
# Copyright 2014 matrix.org
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from twisted.internet import defer
from ._base import BaseHandler
import logging
from collections import namedtuple
logger = logging.getLogger(__name__)
# A tiny object useful for storing a user's membership in a room, as a mapping
# key
RoomMember = namedtuple("RoomMember", ("room_id", "user"))
class TypingNotificationHandler(BaseHandler):
def __init__(self, hs):
super(TypingNotificationHandler, self).__init__(hs)
self.homeserver = hs
self.clock = hs.get_clock()
self.federation = hs.get_replication_layer()
self.federation.register_edu_handler("m.typing", self._recv_edu)
self._member_typing_until = {}
@defer.inlineCallbacks
def started_typing(self, target_user, auth_user, room_id, timeout):
if not target_user.is_mine:
raise SynapseError(400, "User is not hosted on this Home Server")
if target_user != auth_user:
raise AuthError(400, "Cannot set another user's typing state")
until = self.clock.time_msec() + timeout
member = RoomMember(room_id=room_id, user=target_user)
was_present = member in self._member_typing_until
self._member_typing_until[member] = until
if was_present:
# No point sending another notification
defer.returnValue(None)
yield self._push_update(
room_id=room_id,
user=target_user,
typing=True,
)
@defer.inlineCallbacks
def stopped_typing(self, target_user, auth_user, room_id):
if not target_user.is_mine:
raise SynapseError(400, "User is not hosted on this Home Server")
if target_user != auth_user:
raise AuthError(400, "Cannot set another user's typing state")
member = RoomMember(room_id=room_id, user=target_user)
if member not in self._member_typing_until:
# No point
defer.returnValue(None)
yield self._push_update(
room_id=room_id,
user=target_user,
typing=False,
)
@defer.inlineCallbacks
def _push_update(self, room_id, user, typing):
localusers = set()
remotedomains = set()
rm_handler = self.homeserver.get_handlers().room_member_handler
yield rm_handler.fetch_room_distributions_into(room_id,
localusers=localusers, remotedomains=remotedomains,
ignore_user=user)
for u in localusers:
self.push_update_to_clients(
room_id=room_id,
observer_user=u,
observed_user=user,
typing=typing,
)
deferreds = []
for domain in remotedomains:
deferreds.append(self.federation.send_edu(
destination=domain,
edu_type="m.typing",
content={
"room_id": room_id,
"user_id": user.to_string(),
"typing": typing,
},
))
yield defer.DeferredList(deferreds, consumeErrors=False)
@defer.inlineCallbacks
def _recv_edu(self, origin, content):
room_id = content["room_id"]
user = self.homeserver.parse_userid(content["user_id"])
localusers = set()
rm_handler = self.homeserver.get_handlers().room_member_handler
yield rm_handler.fetch_room_distributions_into(room_id,
localusers=localusers)
for u in localusers:
self.push_update_to_clients(
room_id=room_id,
observer_user=u,
observed_user=user,
typing=content["typing"]
)
def push_update_to_clients(self, room_id, observer_user, observed_user,
typing):
# TODO(paul) steal this from presence.py
pass

View File

@ -15,8 +15,7 @@
from . import (
room, events, register, login, profile, public, presence, initial_sync,
directory
room, events, register, login, profile, presence, initial_sync, directory
)
@ -40,7 +39,6 @@ class RestServletFactory(object):
register.register_servlets(hs, client_resource)
login.register_servlets(hs, client_resource)
profile.register_servlets(hs, client_resource)
public.register_servlets(hs, client_resource)
presence.register_servlets(hs, client_resource)
initial_sync.register_servlets(hs, client_resource)
directory.register_servlets(hs, client_resource)

View File

@ -31,7 +31,7 @@ def register_servlets(hs, http_server):
class ClientDirectoryServer(RestServlet):
PATTERN = client_path_pattern("/ds/room/(?P<room_alias>[^/]*)$")
PATTERN = client_path_pattern("/directory/room/(?P<room_alias>[^/]*)$")
@defer.inlineCallbacks
def on_GET(self, request, room_alias):

View File

@ -68,7 +68,7 @@ class PresenceStatusRestServlet(RestServlet):
class PresenceListRestServlet(RestServlet):
PATTERN = client_path_pattern("/presence_list/(?P<user_id>[^/]*)")
PATTERN = client_path_pattern("/presence/list/(?P<user_id>[^/]*)")
@defer.inlineCallbacks
def on_GET(self, request, user_id):

View File

@ -1,33 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2014 matrix.org
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""This module contains REST servlets to do with public paths: /public"""
from twisted.internet import defer
from base import RestServlet, client_path_pattern
class PublicRoomListRestServlet(RestServlet):
PATTERN = client_path_pattern("/public/rooms$")
@defer.inlineCallbacks
def on_GET(self, request):
handler = self.handlers.room_list_handler
data = yield handler.get_public_room_list()
defer.returnValue((200, data))
def register_servlets(hs, http_server):
PublicRoomListRestServlet(hs).register(http_server)

View File

@ -34,31 +34,28 @@ class RoomCreateRestServlet(RestServlet):
# No PATTERN; we have custom dispatch rules here
def register(self, http_server):
# /rooms OR /rooms/<roomid>
http_server.register_path("POST",
client_path_pattern("/rooms$"),
self.on_POST)
http_server.register_path("PUT",
client_path_pattern(
"/rooms/(?P<room_id>[^/]*)$"),
self.on_PUT)
PATTERN = "/createRoom"
register_txn_path(self, PATTERN, http_server)
# define CORS for all of /rooms in RoomCreateRestServlet for simplicity
http_server.register_path("OPTIONS",
client_path_pattern("/rooms(?:/.*)?$"),
self.on_OPTIONS)
# define CORS for /createRoom[/txnid]
http_server.register_path("OPTIONS",
client_path_pattern("/createRoom(?:/.*)?$"),
self.on_OPTIONS)
@defer.inlineCallbacks
def on_PUT(self, request, room_id):
room_id = urllib.unquote(room_id)
auth_user = yield self.auth.get_user_by_req(request)
def on_PUT(self, request, txn_id):
try:
defer.returnValue(self.txns.get_client_transaction(request, txn_id))
except KeyError:
pass
if not room_id:
raise SynapseError(400, "PUT must specify a room ID")
response = yield self.on_POST(request)
room_config = self.get_room_config(request)
info = yield self.make_room(room_config, auth_user, room_id)
room_config.update(info)
defer.returnValue((200, info))
self.txns.store_client_transaction(request, txn_id, response)
defer.returnValue(response)
@defer.inlineCallbacks
def on_POST(self, request):
@ -267,6 +264,17 @@ class JoinRoomAliasServlet(RestServlet):
defer.returnValue(response)
# TODO: Needs unit testing
class PublicRoomListRestServlet(RestServlet):
PATTERN = client_path_pattern("/publicRooms$")
@defer.inlineCallbacks
def on_GET(self, request):
handler = self.handlers.room_list_handler
data = yield handler.get_public_room_list()
defer.returnValue((200, data))
# TODO: Needs unit testing
class RoomMemberListRestServlet(RestServlet):
PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/members$")
@ -314,6 +322,50 @@ class RoomMessageListRestServlet(RestServlet):
defer.returnValue((200, msgs))
# TODO: Needs unit testing
class RoomStateRestServlet(RestServlet):
PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/state$")
@defer.inlineCallbacks
def on_GET(self, request, room_id):
user = yield self.auth.get_user_by_req(request)
# TODO: Get all the current state for this room and return in the same
# format as initial sync, that is:
# [
# { state event }, { state event }
# ]
defer.returnValue((200, []))
# TODO: Needs unit testing
class RoomInitialSyncRestServlet(RestServlet):
PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/initialSync$")
@defer.inlineCallbacks
def on_GET(self, request, room_id):
user = yield self.auth.get_user_by_req(request)
# TODO: Get all the initial sync data for this room and return in the
# same format as initial sync, that is:
# {
# membership: join,
# messages: [
# chunk: [ msg events ],
# start: s_tok,
# end: e_tok
# ],
# room_id: foo,
# state: [
# { state event } , { state event }
# ]
# }
# Probably worth keeping the keys room_id and membership for parity with
# /initialSync even though they must be joined to sync this and know the
# room ID, so clients can reuse the same code (room_id and membership
# are MANDATORY for /initialSync, so the code will expect it to be
# there)
defer.returnValue((200, {}))
class RoomTriggerBackfill(RestServlet):
PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/backfill$")
@ -427,3 +479,6 @@ def register_servlets(hs, http_server):
RoomTriggerBackfill(hs).register(http_server)
RoomMembershipRestServlet(hs).register(http_server)
RoomSendEventRestServlet(hs).register(http_server)
PublicRoomListRestServlet(hs).register(http_server)
RoomStateRestServlet(hs).register(http_server)
RoomInitialSyncRestServlet(hs).register(http_server)

View File

@ -124,6 +124,11 @@ class BaseHomeServer(object):
object."""
return UserID.from_string(s, hs=self)
def parse_roomid(self, s):
"""Parse the string given by 's' as a Room ID and return a RoomID
object."""
return RoomID.from_string(s, hs=self)
def parse_roomalias(self, s):
"""Parse the string given by 's' as a Room Alias and return a RoomAlias
object."""

View File

@ -0,0 +1,250 @@
# -*- coding: utf-8 -*-
# Copyright 2014 matrix.org
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from twisted.trial import unittest
from twisted.internet import defer
from mock import Mock, call, ANY
import json
import logging
from ..utils import MockHttpResource, MockClock, DeferredMockCallable
from synapse.server import HomeServer
from synapse.handlers.typing import TypingNotificationHandler
logging.getLogger().addHandler(logging.NullHandler())
def _expect_edu(destination, edu_type, content, origin="test"):
return {
"origin": origin,
"ts": 1000000,
"pdus": [],
"edus": [
{
"origin": origin,
"destination": destination,
"edu_type": edu_type,
"content": content,
}
],
}
def _make_edu_json(origin, edu_type, content):
return json.dumps(_expect_edu("test", edu_type, content, origin=origin))
class JustTypingNotificationHandlers(object):
def __init__(self, hs):
self.typing_notification_handler = TypingNotificationHandler(hs)
class TypingNotificationsTestCase(unittest.TestCase):
"""Tests typing notifications to rooms."""
def setUp(self):
self.clock = MockClock()
self.mock_http_client = Mock(spec=[])
self.mock_http_client.put_json = DeferredMockCallable()
self.mock_federation_resource = MockHttpResource()
hs = HomeServer("test",
clock=self.clock,
db_pool=None,
datastore=Mock(spec=[
# Bits that Federation needs
"prep_send_transaction",
"delivered_txn",
"get_received_txn_response",
"set_received_txn_response",
]),
handlers=None,
resource_for_client=Mock(),
resource_for_federation=self.mock_federation_resource,
http_client=self.mock_http_client,
)
hs.handlers = JustTypingNotificationHandlers(hs)
self.mock_update_client = Mock()
self.mock_update_client.return_value = defer.succeed(None)
self.handler = hs.get_handlers().typing_notification_handler
self.handler.push_update_to_clients = self.mock_update_client
self.datastore = hs.get_datastore()
def get_received_txn_response(*args):
return defer.succeed(None)
self.datastore.get_received_txn_response = get_received_txn_response
self.room_id = "a-room"
# Mock the RoomMemberHandler
hs.handlers.room_member_handler = Mock(spec=[])
self.room_member_handler = hs.handlers.room_member_handler
self.room_members = []
def get_rooms_for_user(user):
if user in self.room_members:
return defer.succeed([self.room_id])
else:
return defer.succeed([])
self.room_member_handler.get_rooms_for_user = get_rooms_for_user
def get_room_members(room_id):
if room_id == self.room_id:
return defer.succeed(self.room_members)
else:
return defer.succeed([])
self.room_member_handler.get_room_members = get_room_members
@defer.inlineCallbacks
def fetch_room_distributions_into(room_id, localusers=None,
remotedomains=None, ignore_user=None):
members = yield get_room_members(room_id)
for member in members:
if ignore_user is not None and member == ignore_user:
continue
if member.is_mine:
if localusers is not None:
localusers.add(member)
else:
if remotedomains is not None:
remotedomains.add(member.domain)
self.room_member_handler.fetch_room_distributions_into = (
fetch_room_distributions_into)
# Some local users to test with
self.u_apple = hs.parse_userid("@apple:test")
self.u_banana = hs.parse_userid("@banana:test")
# Remote user
self.u_onion = hs.parse_userid("@onion:farm")
@defer.inlineCallbacks
def test_started_typing_local(self):
self.room_members = [self.u_apple, self.u_banana]
yield self.handler.started_typing(
target_user=self.u_apple,
auth_user=self.u_apple,
room_id=self.room_id,
timeout=20000,
)
self.mock_update_client.assert_has_calls([
call(observer_user=self.u_banana,
observed_user=self.u_apple,
room_id=self.room_id,
typing=True),
])
@defer.inlineCallbacks
def test_started_typing_remote_send(self):
self.room_members = [self.u_apple, self.u_onion]
put_json = self.mock_http_client.put_json
put_json.expect_call_and_return(
call("farm",
path="/matrix/federation/v1/send/1000000/",
data=_expect_edu("farm", "m.typing",
content={
"room_id": self.room_id,
"user_id": self.u_apple.to_string(),
"typing": True,
}
)
),
defer.succeed((200, "OK"))
)
yield self.handler.started_typing(
target_user=self.u_apple,
auth_user=self.u_apple,
room_id=self.room_id,
timeout=20000,
)
yield put_json.await_calls()
@defer.inlineCallbacks
def test_started_typing_remote_recv(self):
self.room_members = [self.u_apple, self.u_onion]
yield self.mock_federation_resource.trigger("PUT",
"/matrix/federation/v1/send/1000000/",
_make_edu_json("farm", "m.typing",
content={
"room_id": self.room_id,
"user_id": self.u_onion.to_string(),
"typing": True,
}
)
)
self.mock_update_client.assert_has_calls([
call(observer_user=self.u_apple,
observed_user=self.u_onion,
room_id=self.room_id,
typing=True),
])
@defer.inlineCallbacks
def test_stopped_typing(self):
self.room_members = [self.u_apple, self.u_banana, self.u_onion]
put_json = self.mock_http_client.put_json
put_json.expect_call_and_return(
call("farm",
path="/matrix/federation/v1/send/1000000/",
data=_expect_edu("farm", "m.typing",
content={
"room_id": self.room_id,
"user_id": self.u_apple.to_string(),
"typing": False,
}
)
),
defer.succeed((200, "OK"))
)
# Gut-wrenching
from synapse.handlers.typing import RoomMember
self.handler._member_typing_until[
RoomMember(self.room_id, self.u_apple)
] = 1002000
yield self.handler.stopped_typing(
target_user=self.u_apple,
auth_user=self.u_apple,
room_id=self.room_id,
)
self.mock_update_client.assert_has_calls([
call(observer_user=self.u_banana,
observed_user=self.u_apple,
room_id=self.room_id,
typing=False),
])
yield put_json.await_calls()

View File

@ -179,9 +179,8 @@ class EventStreamPermissionsTestCase(RestTestCase):
@defer.inlineCallbacks
def test_stream_room_permissions(self):
room_id = "!rid1:test"
yield self.create_room_as(room_id, self.other_user,
tok=self.other_token)
room_id = yield self.create_room_as(self.other_user,
tok=self.other_token)
yield self.send(room_id, tok=self.other_token)
# invited to room (expect no content for room)

View File

@ -171,7 +171,7 @@ class PresenceListTestCase(unittest.TestCase):
)
(code, response) = yield self.mock_resource.trigger("GET",
"/presence_list/%s" % (myid), None)
"/presence/list/%s" % (myid), None)
self.assertEquals(200, code)
self.assertEquals(
@ -192,7 +192,7 @@ class PresenceListTestCase(unittest.TestCase):
)
(code, response) = yield self.mock_resource.trigger("POST",
"/presence_list/%s" % (myid),
"/presence/list/%s" % (myid),
"""{"invite": ["@banana:test"]}"""
)
@ -212,7 +212,7 @@ class PresenceListTestCase(unittest.TestCase):
)
(code, response) = yield self.mock_resource.trigger("POST",
"/presence_list/%s" % (myid),
"/presence/list/%s" % (myid),
"""{"drop": ["@banana:test"]}"""
)

View File

@ -74,13 +74,11 @@ class RoomPermissionsTestCase(RestTestCase):
# create some rooms under the name rmcreator_id
self.uncreated_rmid = "!aa:test"
self.created_rmid = "!abc:test"
yield self.create_room_as(self.created_rmid, self.rmcreator_id,
is_public=False)
self.created_rmid = yield self.create_room_as(self.rmcreator_id,
is_public=False)
self.created_public_rmid = "!def1234ghi:test"
yield self.create_room_as(self.created_public_rmid, self.rmcreator_id,
is_public=True)
self.created_public_rmid = yield self.create_room_as(self.rmcreator_id,
is_public=True)
# send a message in one of the rooms
self.created_rmid_msg_path = ("/rooms/%s/send/m.room.message/a1" %
@ -423,8 +421,7 @@ class RoomsMemberListTestCase(RestTestCase):
@defer.inlineCallbacks
def test_get_member_list(self):
room_id = "!aa:test"
yield self.create_room_as(room_id, self.user_id)
room_id = yield self.create_room_as(self.user_id)
(code, response) = yield self.mock_resource.trigger_get(
"/rooms/%s/members" % room_id)
self.assertEquals(200, code, msg=str(response))
@ -437,18 +434,16 @@ class RoomsMemberListTestCase(RestTestCase):
@defer.inlineCallbacks
def test_get_member_list_no_permission(self):
room_id = "!bb:test"
yield self.create_room_as(room_id, "@some_other_guy:red")
room_id = yield self.create_room_as("@some_other_guy:red")
(code, response) = yield self.mock_resource.trigger_get(
"/rooms/%s/members" % room_id)
self.assertEquals(403, code, msg=str(response))
@defer.inlineCallbacks
def test_get_member_list_mixed_memberships(self):
room_id = "!bb:test"
room_creator = "@some_other_guy:blue"
room_id = yield self.create_room_as(room_creator)
room_path = "/rooms/%s/members" % room_id
yield self.create_room_as(room_id, room_creator)
yield self.invite(room=room_id, src=room_creator,
targ=self.user_id)
# can't see list if you're just invited.
@ -503,107 +498,57 @@ class RoomsCreateTestCase(RestTestCase):
@defer.inlineCallbacks
def test_post_room_no_keys(self):
# POST with no config keys, expect new room id
(code, response) = yield self.mock_resource.trigger("POST", "/rooms",
"{}")
(code, response) = yield self.mock_resource.trigger("POST",
"/createRoom",
"{}")
self.assertEquals(200, code, response)
self.assertTrue("room_id" in response)
@defer.inlineCallbacks
def test_post_room_visibility_key(self):
# POST with visibility config key, expect new room id
(code, response) = yield self.mock_resource.trigger("POST", "/rooms",
'{"visibility":"private"}')
(code, response) = yield self.mock_resource.trigger(
"POST",
"/createRoom",
'{"visibility":"private"}')
self.assertEquals(200, code)
self.assertTrue("room_id" in response)
@defer.inlineCallbacks
def test_post_room_custom_key(self):
# POST with custom config keys, expect new room id
(code, response) = yield self.mock_resource.trigger("POST", "/rooms",
'{"custom":"stuff"}')
(code, response) = yield self.mock_resource.trigger(
"POST",
"/createRoom",
'{"custom":"stuff"}')
self.assertEquals(200, code)
self.assertTrue("room_id" in response)
@defer.inlineCallbacks
def test_post_room_known_and_unknown_keys(self):
# POST with custom + known config keys, expect new room id
(code, response) = yield self.mock_resource.trigger("POST", "/rooms",
'{"visibility":"private","custom":"things"}')
(code, response) = yield self.mock_resource.trigger(
"POST",
"/createRoom",
'{"visibility":"private","custom":"things"}')
self.assertEquals(200, code)
self.assertTrue("room_id" in response)
@defer.inlineCallbacks
def test_post_room_invalid_content(self):
# POST with invalid content / paths, expect 400
(code, response) = yield self.mock_resource.trigger("POST", "/rooms",
'{"visibili')
self.assertEquals(400, code)
(code, response) = yield self.mock_resource.trigger("POST", "/rooms",
'["hello"]')
self.assertEquals(400, code)
@defer.inlineCallbacks
def test_put_room_no_keys(self):
# PUT with no config keys, expect new room id
(code, response) = yield self.mock_resource.trigger(
"PUT", "/rooms/%21aa%3Atest", "{}"
)
self.assertEquals(200, code)
self.assertTrue("room_id" in response)
@defer.inlineCallbacks
def test_put_room_visibility_key(self):
# PUT with known config keys, expect new room id
(code, response) = yield self.mock_resource.trigger(
"PUT", "/rooms/%21bb%3Atest", '{"visibility":"private"}'
)
self.assertEquals(200, code)
self.assertTrue("room_id" in response)
@defer.inlineCallbacks
def test_put_room_custom_key(self):
# PUT with custom config keys, expect new room id
(code, response) = yield self.mock_resource.trigger(
"PUT", "/rooms/%21cc%3Atest", '{"custom":"stuff"}'
)
self.assertEquals(200, code)
self.assertTrue("room_id" in response)
@defer.inlineCallbacks
def test_put_room_known_and_unknown_keys(self):
# PUT with custom + known config keys, expect new room id
(code, response) = yield self.mock_resource.trigger(
"PUT", "/rooms/%21dd%3Atest",
'{"visibility":"private","custom":"things"}'
)
self.assertEquals(200, code)
self.assertTrue("room_id" in response)
@defer.inlineCallbacks
def test_put_room_invalid_content(self):
# PUT with invalid content / room names, expect 400
(code, response) = yield self.mock_resource.trigger(
"PUT", "/rooms/ee", '{"sdf"'
)
"POST",
"/createRoom",
'{"visibili')
self.assertEquals(400, code)
(code, response) = yield self.mock_resource.trigger(
"PUT", "/rooms/ee", '["hello"]'
)
"POST",
"/createRoom",
'["hello"]')
self.assertEquals(400, code)
@defer.inlineCallbacks
def test_put_room_conflict(self):
yield self.create_room_as("!aa:test", self.user_id)
# PUT with conflicting room ID, expect 409
(code, response) = yield self.mock_resource.trigger(
"PUT", "/rooms/%21aa%3Atest", "{}"
)
self.assertEquals(409, code)
class RoomTopicTestCase(RestTestCase):
""" Tests /rooms/$room_id/topic REST events. """
@ -613,8 +558,6 @@ class RoomTopicTestCase(RestTestCase):
def setUp(self):
self.mock_resource = MockHttpResource(prefix=PATH_PREFIX)
self.auth_user_id = self.user_id
self.room_id = "!rid1:test"
self.path = "/rooms/%s/state/m.room.topic" % self.room_id
state_handler = Mock(spec=["handle_new_event"])
state_handler.handle_new_event.return_value = True
@ -640,7 +583,8 @@ class RoomTopicTestCase(RestTestCase):
synapse.rest.room.register_servlets(hs, self.mock_resource)
# create the room
yield self.create_room_as(self.room_id, self.user_id)
self.room_id = yield self.create_room_as(self.user_id)
self.path = "/rooms/%s/state/m.room.topic" % self.room_id
def tearDown(self):
pass
@ -717,7 +661,6 @@ class RoomMemberStateTestCase(RestTestCase):
def setUp(self):
self.mock_resource = MockHttpResource(prefix=PATH_PREFIX)
self.auth_user_id = self.user_id
self.room_id = "!rid1:test"
state_handler = Mock(spec=["handle_new_event"])
state_handler.handle_new_event.return_value = True
@ -742,7 +685,7 @@ class RoomMemberStateTestCase(RestTestCase):
synapse.rest.room.register_servlets(hs, self.mock_resource)
yield self.create_room_as(self.room_id, self.user_id)
self.room_id = yield self.create_room_as(self.user_id)
def tearDown(self):
pass
@ -843,7 +786,6 @@ class RoomMessagesTestCase(RestTestCase):
def setUp(self):
self.mock_resource = MockHttpResource(prefix=PATH_PREFIX)
self.auth_user_id = self.user_id
self.room_id = "!rid1:test"
state_handler = Mock(spec=["handle_new_event"])
state_handler.handle_new_event.return_value = True
@ -868,7 +810,7 @@ class RoomMessagesTestCase(RestTestCase):
synapse.rest.room.register_servlets(hs, self.mock_resource)
yield self.create_room_as(self.room_id, self.user_id)
self.room_id = yield self.create_room_as(self.user_id)
def tearDown(self):
pass

View File

@ -24,6 +24,7 @@ from synapse.api.constants import Membership
import json
import time
class RestTestCase(unittest.TestCase):
"""Contains extra helper functions to quickly and clearly perform a given
REST action, which isn't the focus of the test.
@ -40,18 +41,19 @@ class RestTestCase(unittest.TestCase):
return self.auth_user_id
@defer.inlineCallbacks
def create_room_as(self, room_id, room_creator, is_public=True, tok=None):
def create_room_as(self, room_creator, is_public=True, tok=None):
temp_id = self.auth_user_id
self.auth_user_id = room_creator
path = "/rooms/%s" % room_id
path = "/createRoom"
content = "{}"
if not is_public:
content = '{"visibility":"private"}'
if tok:
path = path + "?access_token=%s" % tok
(code, response) = yield self.mock_resource.trigger("PUT", path, content)
(code, response) = yield self.mock_resource.trigger("POST", path, content)
self.assertEquals(200, code, msg=str(response))
self.auth_user_id = temp_id
defer.returnValue(response["room_id"])
@defer.inlineCallbacks
def invite(self, room=None, src=None, targ=None, expect_code=200, tok=None):

View File

@ -37,7 +37,11 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
mPresence.start();
}
$scope.go = function(url) {
/**
* Open a given page.
* @param {String} url url of the page
*/
$scope.goToPage = function(url) {
$location.url(url);
};

View File

@ -342,6 +342,64 @@ h1 {
top: 0;
}
/*** Recents ***/
.recentsTable {
max-width: 480px;
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.recentsTable tr {
width: 100%;
}
.recentsTable td {
vertical-align: text-top;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.recentsRoom {
cursor: pointer;
}
.recentsRoom:hover {
background-color: #f8f8ff;
}
.recentsRoomSelected {
background-color: #eee;
}
.recentsRoomName {
font-size: 16px;
padding-top: 7px;
width: auto;
}
.recentsRoomSummaryTS {
color: #888;
font-size: 12px;
width: 7em;
text-align: right;
}
.recentsRoomSummary {
color: #888;
font-size: 12px;
padding-bottom: 5px;
}
/*** Recents in the room page ***/
#roomRecentsTableWrapper {
float: left;
max-width: 320px;
margin-right: 20px;
height: 100%;
overflow-y: auto;
}
/*** Profile ***/
.profile-avatar {

View File

@ -20,6 +20,7 @@ var matrixWebClient = angular.module('matrixWebClient', [
'LoginController',
'RoomController',
'HomeController',
'RecentsController',
'SettingsController',
'UserController',
'matrixService',

View File

@ -97,7 +97,7 @@ angular.module('matrixService', [])
// Create a room
create: function(room_id, visibility) {
// The REST path spec
var path = "/rooms";
var path = "/createRoom";
return doRequest("POST", path, undefined, {
visibility: visibility,
@ -106,11 +106,20 @@ angular.module('matrixService', [])
},
// List all rooms joined or been invited to
rooms: function(from, to, limit) {
rooms: function(limit, feedback) {
// The REST path spec
var path = "/initialSync";
return doRequest("GET", path);
var params = {};
if (limit) {
params.limit = limit;
}
if (feedback) {
params.feedback = feedback;
}
return doRequest("GET", path, params);
},
// Joins a room
@ -124,7 +133,8 @@ angular.module('matrixService', [])
path = path.replace("$room_alias", room_alias);
return doRequest("PUT", path, undefined, {});
// TODO: PUT with txn ID
return doRequest("POST", path, undefined, {});
},
// Invite a user to a room
@ -154,7 +164,7 @@ angular.module('matrixService', [])
// Retrieves the room ID corresponding to a room alias
resolveRoomAlias:function(room_alias) {
var path = "/matrix/client/api/v1/ds/room/$room_alias";
var path = "/matrix/client/api/v1/directory/room/$room_alias";
room_alias = encodeURIComponent(room_alias);
path = path.replace("$room_alias", room_alias);
@ -234,7 +244,7 @@ angular.module('matrixService', [])
// get a list of public rooms on your home server
publicRooms: function() {
var path = "/public/rooms"
var path = "/publicRooms"
return doRequest("GET", path);
},
@ -405,6 +415,40 @@ angular.module('matrixService', [])
config.version = configVersion;
localStorage.setItem("config", JSON.stringify(config));
},
/****** Room aliases management ******/
/**
* Enhance data returned by rooms() and publicRooms() by adding room_alias
* & room_display_name which are computed from data already retrieved from the server.
* @param {Array} data the response of rooms() and publicRooms()
* @returns {Array} the same array with enriched objects
*/
assignRoomAliases: function(data) {
for (var i=0; i<data.length; i++) {
var alias = this.getRoomIdToAliasMapping(data[i].room_id);
if (alias) {
// use the existing alias from storage
data[i].room_alias = alias;
data[i].room_display_name = alias;
}
else if (data[i].aliases && data[i].aliases[0]) {
// save the mapping
// TODO: select the smarter alias from the array
this.createRoomIdToAliasMapping(data[i].room_id, data[i].aliases[0]);
data[i].room_display_name = data[i].aliases[0];
}
else if (data[i].membership == "invite" && "inviter" in data[i]) {
data[i].room_display_name = data[i].inviter + "'s room"
}
else {
// last resort use the room id
data[i].room_display_name = data[i].room_id;
}
}
return data;
},
createRoomIdToAliasMapping: function(roomId, alias) {
localStorage.setItem(MAPPING_PREFIX+roomId, alias);

View File

@ -16,12 +16,11 @@ limitations under the License.
'use strict';
angular.module('HomeController', ['matrixService', 'mFileInput', 'mFileUpload', 'eventHandlerService'])
.controller('HomeController', ['$scope', '$location', 'matrixService', 'mFileUpload', 'eventHandlerService', 'eventStreamService',
function($scope, $location, matrixService, mFileUpload, eventHandlerService, eventStreamService) {
angular.module('HomeController', ['matrixService', 'eventHandlerService', 'RecentsController'])
.controller('HomeController', ['$scope', '$location', 'matrixService', 'eventHandlerService', 'eventStreamService',
function($scope, $location, matrixService, eventHandlerService, eventStreamService) {
$scope.config = matrixService.config();
$scope.rooms = {};
$scope.public_rooms = [];
$scope.newRoomId = "";
$scope.feedback = "";
@ -32,72 +31,18 @@ angular.module('HomeController', ['matrixService', 'mFileInput', 'mFileUpload',
};
$scope.goToRoom = {
room_id: "",
room_id: ""
};
$scope.joinAlias = {
room_alias: "",
};
$scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
var config = matrixService.config();
if (event.state_key === config.user_id && event.content.membership === "invite") {
console.log("Invited to room " + event.room_id);
// FIXME push membership to top level key to match /im/sync
event.membership = event.content.membership;
// FIXME bodge a nicer name than the room ID for this invite.
event.room_display_name = event.user_id + "'s room";
$scope.rooms[event.room_id] = event;
}
});
var assignRoomAliases = function(data) {
for (var i=0; i<data.length; i++) {
var alias = matrixService.getRoomIdToAliasMapping(data[i].room_id);
if (alias) {
// use the existing alias from storage
data[i].room_alias = alias;
data[i].room_display_name = alias;
}
else if (data[i].aliases && data[i].aliases[0]) {
// save the mapping
// TODO: select the smarter alias from the array
matrixService.createRoomIdToAliasMapping(data[i].room_id, data[i].aliases[0]);
data[i].room_display_name = data[i].aliases[0];
}
else if (data[i].membership == "invite" && "inviter" in data[i]) {
data[i].room_display_name = data[i].inviter + "'s room"
}
else {
// last resort use the room id
data[i].room_display_name = data[i].room_id;
}
}
return data;
room_alias: ""
};
var refresh = function() {
// List all rooms joined or been invited to
matrixService.rooms(1,true).then(
function(response) {
var data = assignRoomAliases(response.data.rooms);
$scope.feedback = "Success";
for (var i=0; i<data.length; i++) {
$scope.rooms[data[i].room_id] = data[i];
}
var presence = response.data.presence;
for (var i = 0; i < presence.length; ++i) {
eventHandlerService.handleEvent(presence[i], false);
}
},
function(error) {
$scope.feedback = "Failure: " + error.data;
});
matrixService.publicRooms().then(
function(response) {
$scope.public_rooms = assignRoomAliases(response.data.chunk);
$scope.public_rooms = matrixService.assignRoomAliases(response.data.chunk);
}
);

View File

@ -23,13 +23,8 @@
</form>
</div>
<h3>My rooms</h3>
<div class="rooms" ng-repeat="(rm_id, room) in rooms">
<div>
<a href="#/room/{{ room.room_alias ? room.room_alias : rm_id }}" >{{ room.room_display_name }}</a> {{room.membership === 'invite' ? ' (invited)' : ''}}
</div>
</div>
<h3>Recents</h3>
<div ng-include="'recents/recents.html'"></div>
<br/>
<h3>Public rooms</h3>

View File

@ -19,6 +19,8 @@
<script src="app-filter.js"></script>
<script src="home/home-controller.js"></script>
<script src="login/login-controller.js"></script>
<script src="recents/recents-controller.js"></script>
<script src="recents/recents-filter.js"></script>
<script src="room/room-controller.js"></script>
<script src="room/room-directive.js"></script>
<script src="settings/settings-controller.js"></script>
@ -37,7 +39,7 @@
<header id="header">
<!-- Do not show buttons on the login page -->
<div id="header-buttons" ng-hide="'/login' == location ">
<button ng-click='go("settings")'>Settings</button>
<button ng-click='goToPage("settings")'>Settings</button>
<button ng-click="logout()">Log out</button>
</div>
</header>

View File

@ -0,0 +1,70 @@
/*
Copyright 2014 matrix.org
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
angular.module('RecentsController', ['matrixService', 'eventHandlerService'])
.controller('RecentsController', ['$scope', 'matrixService', 'eventHandlerService', 'eventStreamService',
function($scope, matrixService, eventHandlerService, eventStreamService) {
$scope.rooms = {};
// $scope of the parent where the recents component is included can override this value
// in order to highlight a specific room in the list
$scope.recentsSelectedRoomID;
$scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
var config = matrixService.config();
if (event.state_key === config.user_id && event.content.membership === "invite") {
console.log("Invited to room " + event.room_id);
// FIXME push membership to top level key to match /im/sync
event.membership = event.content.membership;
// FIXME bodge a nicer name than the room ID for this invite.
event.room_display_name = event.user_id + "'s room";
$scope.rooms[event.room_id] = event;
}
});
var refresh = function() {
// List all rooms joined or been invited to
matrixService.rooms(1, false).then(
function(response) {
var data = matrixService.assignRoomAliases(response.data.rooms);
for (var i=0; i<data.length; i++) {
$scope.rooms[data[i].room_id] = data[i];
// Create a shortcut for the last message of this room
if (data[i].messages && data[i].messages.chunk && data[i].messages.chunk[0]) {
$scope.rooms[data[i].room_id].lastMsg = data[i].messages.chunk[0];
}
}
var presence = response.data.presence;
for (var i = 0; i < presence.length; ++i) {
eventHandlerService.handleEvent(presence[i], false);
}
},
function(error) {
$scope.feedback = "Failure: " + error.data;
}
);
};
$scope.onInit = function() {
refresh();
};
}]);

View File

@ -0,0 +1,47 @@
/*
Copyright 2014 matrix.org
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
angular.module('RecentsController')
.filter('orderRecents', function() {
return function(rooms) {
// Transform the dict into an array
// The key, room_id, is already in value objects
var filtered = [];
angular.forEach(rooms, function(value, key) {
filtered.push( value );
});
// And time sort them
// The room with the lastest message at first
filtered.sort(function (a, b) {
// Invite message does not have a body message nor ts
// Puth them at the top of the list
if (undefined === a.lastMsg) {
return -1;
}
else if (undefined === b.lastMsg) {
return 1;
}
else {
return b.lastMsg.ts - a.lastMsg.ts;
}
});
return filtered;
};
});

View File

@ -0,0 +1,56 @@
<div ng-controller="RecentsController" data-ng-init="onInit()">
<table class="recentsTable">
<tbody ng-repeat="(rm_id, room) in rooms | orderRecents"
ng-click="goToPage('room/' + (room.room_alias ? room.room_alias : room.room_id) )"
class ="recentsRoom"
ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">
<tr>
<td class="recentsRoomName">
{{ room.room_display_name }}
</td>
<td class="recentsRoomSummaryTS">
{{ (room.lastMsg.ts) | date:'MMM d HH:mm' }}
</td>
</tr>
<tr>
<td colspan="2" class="recentsRoomSummary">
<div ng-show="room.membership === 'invite'" >
{{ room.inviter }} invited you
</div>
<div ng-hide="room.membership === 'invite'" ng-switch="room.lastMsg.type" >
<div ng-switch-when="m.room.member">
{{ room.lastMsg.user_id }}
{{ {"join": "joined", "leave": "left", "invite": "invited"}[room.lastMsg.content.membership] }}
{{ room.lastMsg.content.membership === "invite" ? (room.lastMsg.state_key || '') : '' }}
</div>
<div ng-switch-when="m.room.message">
<div ng-switch="room.lastMsg.content.msgtype">
<div ng-switch-when="m.text">
{{ room.lastMsg.user_id }} :
<span ng-bind-html="(room.lastMsg.content.body) | linky:'_blank'">
</span>
</div>
<div ng-switch-when="m.image">
{{ room.lastMsg.user_id }} sent an image
</div>
<div ng-switch-default>
{{ room.lastMsg.content }}
</div>
</div>
</div>
<div ng-switch-default>
{{ room.lastMsg }}
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -327,6 +327,9 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
var onInit2 = function() {
eventHandlerService.reInitRoom($scope.room_id);
// Make recents highlight the current room
$scope.recentsSelectedRoomID = $scope.room_id;
// Join the room
matrixService.join($scope.room_id).then(
function() {

View File

@ -7,7 +7,11 @@
<div id="roomName">
{{ room_alias || room_id }}
</div>
<div id="roomRecentsTableWrapper">
<div ng-include="'recents/recents.html'"></div>
</div>
<div id="usersTableWrapper">
<table id="usersTable">
<tr ng-repeat="member in members | orderMembersList">