From eb163c555ac42c07cfb2f3b95dda1ab0e3777a26 Mon Sep 17 00:00:00 2001 From: Petteri Stenius Date: Fri, 12 Jan 2024 01:45:38 +0200 Subject: [PATCH] Feature to get a list of long running operations (#158) * add/operations endpoint * add type filtering parameter to getOperations * getOperations catches error from Monitor.status * add unit test, cleanup * cleanup * api documentation * spec update - adding new endpoint --------- Co-authored-by: Petteri Stenius --- src/keria/app/agenting.py | 6 +- src/keria/core/longrunning.py | 55 +++++++++ tests/app/test_specing.py | 204 +-------------------------------- tests/core/test_longrunning.py | 186 ++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+), 204 deletions(-) create mode 100644 tests/core/test_longrunning.py diff --git a/src/keria/app/agenting.py b/src/keria/app/agenting.py index 9462f5e8..281ae0c7 100644 --- a/src/keria/app/agenting.py +++ b/src/keria/app/agenting.py @@ -757,8 +757,10 @@ def recur(self, tyme): def loadEnds(app): - opEnd = longrunning.OperationResourceEnd() - app.add_route("/operations/{name}", opEnd) + opColEnd = longrunning.OperationCollectionEnd() + app.add_route("/operations", opColEnd) + opResEnd = longrunning.OperationResourceEnd() + app.add_route("/operations/{name}", opResEnd) oobiColEnd = OOBICollectionEnd() app.add_route("/oobis", oobiColEnd) diff --git a/src/keria/core/longrunning.py b/src/keria/core/longrunning.py index c562e6e0..516726b1 100644 --- a/src/keria/core/longrunning.py +++ b/src/keria/core/longrunning.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, asdict import falcon +import json from dataclasses_json import dataclass_json from keri import kering from keri.app.oobiing import Result @@ -138,6 +139,26 @@ def get(self, name): return operation + def getOperations(self, type=None): + """ Return list of long running opterations, optionally filtered by type """ + ops = self.opr.ops.getItemIter() + if type != None: + ops = filter(lambda i: i[1].type == type, ops) + + def get_status(op): + try: + return self.status(op) + except Exception as err: + # self.status may throw an exception. + # Handling error by returning an operation with error status + return Operation( + name=f"{op.type}.{op.oid}", + metadata=op.metadata, + done=True, + error=Status(code=500, message=f"{err}")) + + return [get_status(op) for (_, op) in ops] + def rem(self, name): """ Remove tracking of the long running operation represented by name """ return self.opr.ops.rem(keys=(name,)) @@ -365,6 +386,40 @@ def status(self, op): return operation +class OperationCollectionEnd: + @staticmethod + def on_get(req, rep): + """ Get list of long running operations + + Parameters: + req (Request): Falcon HTTP Request object + rep (Response): Falcon HTTP Response object + + --- + summary: Get list of long running operations + parameters: + - in: query + name: type + schema: + type: string + required: false + description: filter list of long running operations by type + responses: + 200: + content: + application/json: + schema: + type: array + + """ + agent = req.context.agent + type = req.params.get("type") + ops = agent.monitor.getOperations(type=type) + rep.data = json.dumps(ops, default=lambda o: o.to_dict()).encode("utf-8") + rep.content_type = "application/json" + rep.status = falcon.HTTP_200 + + class OperationResourceEnd: """ Single Resource REST endpoint for long running operations diff --git a/tests/app/test_specing.py b/tests/app/test_specing.py index 6fa94a51..575b172e 100644 --- a/tests/app/test_specing.py +++ b/tests/app/test_specing.py @@ -38,211 +38,11 @@ def test_spec_resource(helpers): assert "/oobi/{aid}/{role}/{eid}" in paths assert "/oobis" in paths assert "/oobis/{alias}" in paths + assert "/operations" in paths assert "/operations/{name}" in paths assert "/queries" in paths assert "/states" in paths js = json.dumps(sd) # Assert on the entire JSON to ensure we are getting all the docs - assert js == ('{"paths": {"/oobis": {"post": {"summary": "Resolve OOBI and assign an ' - 'alias for the remote identifier", "description": "Resolve OOBI URL or ' - '`rpy` message by process results of request and assign \'alias\' in ' - 'contact data for resolved identifier", "tags": ["OOBIs"], "requestBody": {' - '"required": true, "content": {"application/json": {"schema": {' - '"description": "OOBI", "properties": {"oobialias": {"type": "string", ' - '"description": "alias to assign to the identifier resolved from this ' - 'OOBI", "required": false}, "url": {"type": "string", "description": "URL ' - 'OOBI"}, "rpy": {"type": "object", "description": "unsigned KERI `rpy` ' - 'event message with endpoints"}}}}}}, "responses": {"202": {"description": ' - '"OOBI resolution to key state successful"}}}}, "/states": {"get": {' - '"summary": "Display key event log (KEL) for given identifier prefix", ' - '"description": "If provided qb64 identifier prefix is in Kevers, ' - 'return the current state of the identifier along with the KEL and all ' - 'associated signatures and receipts", "tags": ["Key Event Log"], ' - '"parameters": [{"in": "path", "name": "prefix", "schema": {"type": ' - '"string"}, "required": true, "description": "qb64 identifier prefix of KEL ' - 'to load"}], "responses": {"200": {"description": "Key event log and key ' - 'state of identifier"}, "404": {"description": "Identifier not found in Key ' - 'event database"}}}}, "/events": {"get": {"summary": "Display key event log ' - '(KEL) for given identifier prefix", "description": "If provided qb64 ' - 'identifier prefix is in Kevers, return the current state of the identifier ' - 'along with the KEL and all associated signatures and receipts", "tags": [' - '"Key Event Log"], "parameters": [{"in": "path", "name": "prefix", ' - '"schema": {"type": "string"}, "required": true, "description": "qb64 ' - 'identifier prefix of KEL to load"}], "responses": {"200": {"description": ' - '"Key event log and key state of identifier"}, "404": {"description": ' - '"Identifier not found in Key event database"}}}}, "/queries": {"post": {' - '"summary": "Display key event log (KEL) for given identifier prefix", ' - '"description": "If provided qb64 identifier prefix is in Kevers, ' - 'return the current state of the identifier along with the KEL and all ' - 'associated signatures and receipts", "tags": ["Query"], "parameters": [{' - '"in": "body", "name": "pre", "schema": {"type": "string"}, "required": ' - 'true, "description": "qb64 identifier prefix of KEL to load"}], ' - '"responses": {"200": {"description": "Key event log and key state of ' - 'identifier"}, "404": {"description": "Identifier not found in Key event ' - 'database"}}}}, "/identifiers": {"get": {}, "options": {}, "post": {}}, ' - '"/challenges": {"get": {"summary": "Get random list of words for a 2 ' - 'factor auth challenge", "description": "Get the list of identifiers ' - 'associated with this agent", "tags": ["Challenge/Response"], "parameters": ' - '[{"in": "query", "name": "strength", "schema": {"type": "int"}, ' - '"description": "cryptographic strength of word list", "required": false}], ' - '"responses": {"200": {"description": "An array of random words", ' - '"content": {"application/json": {"schema": {"description": "Random word ' - 'list", "type": "object", "properties": {"words": {"type": "array", ' - '"description": "random challenge word list", "items": {"type": ' - '"string"}}}}}}}}}}, "/contacts": {"get": {"summary": "Get list of contact ' - 'information associated with remote identifiers", "description": "Get list ' - 'of contact information associated with remote identifiers. All ' - 'information is metadata and kept in local storage only", "tags": [' - '"Contacts"], "parameters": [{"in": "query", "name": "group", "schema": {' - '"type": "string"}, "required": false, "description": "field name to group ' - 'results by"}, {"in": "query", "name": "filter_field", "schema": {"type": ' - '"string"}, "description": "field name to search", "required": false}, ' - '{"in": "query", "name": "filter_value", "schema": {"type": "string"}, ' - '"description": "value to search for", "required": false}], "responses": {' - '"200": {"description": "List of contact information for remote ' - 'identifiers"}}}}, "/notifications": {"get": {"summary": "Get list of ' - 'notifications for the controller of the agent", "description": "Get list ' - 'of notifications for the controller of the agent. Notifications will be ' - 'sorted by creation date/time", "parameters": [{"in": "header", ' - '"name": "Range", "schema": {"type": "string"}, "required": false, ' - '"description": "size of the result list. Defaults to 25"}], "tags": [' - '"Notifications"], "responses": {"200": {"description": "List of contact ' - 'information for remote identifiers"}}}}, "/oobi": {"get": {}}, ' - '"/": {"post": {"summary": "Accept KERI events with attachment headers and ' - 'parse", "description": "Accept KERI events with attachment headers and ' - 'parse.", "tags": ["Events"], "requestBody": {"required": true, "content": ' - '{"application/json": {"schema": {"type": "object", "description": "KERI ' - 'event message"}}}}, "responses": {"204": {"description": "KEL EXN, QRY, ' - 'RPY event accepted."}}}, "put": {"summary": "Accept KERI events with ' - 'attachment headers and parse", "description": "Accept KERI events with ' - 'attachment headers and parse.", "tags": ["Events"], "requestBody": {' - '"required": true, "content": {"application/json": {"schema": {"type": ' - '"object", "description": "KERI event message"}}}}, "responses": {"200": {' - '"description": "Mailbox query response for server sent events"}, ' - '"204": {"description": "KEL or EXN event accepted."}}}}, "/operations/{' - 'name}": {"delete": {}, "get": {}}, "/oobis/{alias}": {"get": {"summary": ' - '"Get OOBI for specific identifier", "description": "Generate OOBI for the ' - 'identifier of the specified alias and role", "tags": ["OOBIs"], ' - '"parameters": [{"in": "path", "name": "alias", "schema": {"type": ' - '"string"}, "required": true, "description": "human readable alias for the ' - 'identifier generate OOBI for"}, {"in": "query", "name": "role", "schema": ' - '{"type": "string"}, "required": true, "description": "role for which to ' - 'generate OOBI"}], "responses": {"200": {"description": "An array of ' - 'Identifier key state information", "content": {"application/json": {' - '"schema": {"description": "Key state information for current identifiers", ' - '"type": "object"}}}}}}}, "/agent/{caid}": {"get": {}, "put": {}}, ' - '"/identifiers/{name}": {"get": {}, "put": {}}, "/endroles/{aid}": {"get": ' - '{}, "post": {}}, "/escrows/rpy": {"get": {}}, "/challenges/{name}": {' - '"post": {"summary": "Sign challenge message and forward to peer ' - 'identifier", "description": "Sign a challenge word list received out of ' - 'bands and send `exn` peer to peer message to recipient", "tags": [' - '"Challenge/Response"], "parameters": [{"in": "path", "name": "name", ' - '"schema": {"type": "string"}, "required": true, "description": "Human ' - 'readable alias for the identifier to create"}], "requestBody": {' - '"required": true, "content": {"application/json": {"schema": {' - '"description": "Challenge response", "properties": {"recipient": {"type": ' - '"string", "description": "human readable alias recipient identifier to ' - 'send signed challenge to"}, "words": {"type": "array", "description": ' - '"challenge in form of word list", "items": {"type": "string"}}}}}}}, ' - '"responses": {"202": {"description": "Success submission of signed ' - 'challenge/response"}}}}, "/contacts/{prefix}": {"delete": {"summary": ' - '"Delete contact information associated with remote identifier", ' - '"description": "Delete contact information associated with remote ' - 'identifier", "tags": ["Contacts"], "parameters": [{"in": "path", ' - '"name": "prefix", "schema": {"type": "string"}, "required": true, ' - '"description": "qb64 identifier prefix of contact to delete"}], ' - '"responses": {"202": {"description": "Contact information successfully ' - 'deleted for prefix"}, "404": {"description": "No contact information found ' - 'for prefix"}}}, "get": {"summary": "Get contact information associated ' - 'with single remote identifier", "description": "Get contact information ' - 'associated with single remote identifier. All information is meta-data ' - 'and kept in local storage only", "tags": ["Contacts"], "parameters": [{' - '"in": "path", "name": "prefix", "schema": {"type": "string"}, "required": ' - 'true, "description": "qb64 identifier prefix of contact to get"}], ' - '"responses": {"200": {"description": "Contact information successfully ' - 'retrieved for prefix"}, "404": {"description": "No contact information ' - 'found for prefix"}}}, "post": {"summary": "Create new contact information ' - 'for an identifier", "description": "Creates new information for an ' - 'identifier, overwriting all existing information for that identifier", ' - '"tags": ["Contacts"], "parameters": [{"in": "path", "name": "prefix", ' - '"schema": {"type": "string"}, "required": true, "description": "qb64 ' - 'identifier prefix to add contact metadata to"}], "requestBody": {' - '"required": true, "content": {"application/json": {"schema": {' - '"description": "Contact information", "type": "object"}}}}, "responses": {' - '"200": {"description": "Updated contact information for remote ' - 'identifier"}, "400": {"description": "Invalid identifier used to update ' - 'contact information"}, "404": {"description": "Prefix not found in ' - 'identifier contact information"}}}, "put": {"summary": "Update provided ' - 'fields in contact information associated with remote identifier prefix", ' - '"description": "Update provided fields in contact information associated ' - 'with remote identifier prefix. All information is metadata and kept in ' - 'local storage only", "tags": ["Contacts"], "parameters": [{"in": "path", ' - '"name": "prefix", "schema": {"type": "string"}, "required": true, ' - '"description": "qb64 identifier prefix to add contact metadata to"}], ' - '"requestBody": {"required": true, "content": {"application/json": {' - '"schema": {"description": "Contact information", "type": "object"}}}}, ' - '"responses": {"200": {"description": "Updated contact information for ' - 'remote identifier"}, "400": {"description": "Invalid identifier used to ' - 'update contact information"}, "404": {"description": "Prefix not found in ' - 'identifier contact information"}}}}, "/notifications/{said}": {"delete": {' - '"summary": "Delete notification", "description": "Delete notification", ' - '"tags": ["Notifications"], "parameters": [{"in": "path", "name": "said", ' - '"schema": {"type": "string"}, "required": true, "description": "qb64 said ' - 'of note to delete"}], "responses": {"202": {"description": "Notification ' - 'successfully deleted for prefix"}, "404": {"description": "No notification ' - 'information found for prefix"}}}, "put": {"summary": "Mark notification as ' - 'read", "description": "Mark notification as read", "tags": [' - '"Notifications"], "parameters": [{"in": "path", "name": "said", "schema": ' - '{"type": "string"}, "required": true, "description": "qb64 said of note to ' - 'mark as read"}], "responses": {"202": {"description": "Notification ' - 'successfully marked as read for prefix"}, "404": {"description": "No ' - 'notification information found for SAID"}}}}, "/oobi/{aid}": {"get": {}}, ' - '"/identifiers/{name}/oobis": {"get": {}}, "/identifiers/{name}/endroles": ' - '{"get": {}, "post": {}}, "/identifiers/{name}/members": {"get": {}}, ' - '"/endroles/{aid}/{role}": {"get": {}, "post": {}}, "/contacts/{' - 'prefix}/img": {"get": {"summary": "Get contact image for identifer ' - 'prefix", "description": "Get contact image for identifer prefix", ' - '"tags": ["Contacts"], "parameters": [{"in": "path", "name": "prefix", ' - '"schema": {"type": "string"}, "required": true, "description": "qb64 ' - 'identifier prefix of contact image to get"}], "responses": {"200": {' - '"description": "Contact information successfully retrieved for prefix", ' - '"content": {"image/jpg": {"schema": {"description": "Image", ' - '"type": "binary"}}}}, "404": {"description": "No contact information found ' - 'for prefix"}}}, "post": {"summary": "Uploads an image to associate with ' - 'identifier.", "description": "Uploads an image to associate with ' - 'identifier.", "tags": ["Contacts"], "parameters": [{"in": "path", ' - '"name": "prefix", "schema": {"type": "string"}, "description": "identifier ' - 'prefix to associate image to", "required": true}], "requestBody": {' - '"required": true, "content": {"image/jpg": {"schema": {"type": "string", ' - '"format": "binary"}}, "image/png": {"schema": {"type": "string", "format": ' - '"binary"}}}}, "responses": {"200": {"description": "Image successfully ' - 'uploaded"}}}}, "/oobi/{aid}/{role}": {"get": {}}, "/identifiers/{' - 'name}/endroles/{role}": {"get": {}, "post": {}}, "/challenges/{' - 'name}/verify/{source}": {"post": {"summary": "Sign challenge message and ' - 'forward to peer identifier", "description": "Sign a challenge word list ' - 'received out of bands and send `exn` peer to peer message to recipient", ' - '"tags": ["Challenge/Response"], "parameters": [{"in": "path", ' - '"name": "name", "schema": {"type": "string"}, "required": true, ' - '"description": "Human readable alias for the identifier to create"}], ' - '"requestBody": {"required": true, "content": {"application/json": {' - '"schema": {"description": "Challenge response", "properties": {' - '"recipient": {"type": "string", "description": "human readable alias ' - 'recipient identifier to send signed challenge to"}, "words": {"type": ' - '"array", "description": "challenge in form of word list", "items": {' - '"type": "string"}}}}}}}, "responses": {"202": {"description": "Success ' - 'submission of signed challenge/response"}}}, "put": {"summary": "Mark ' - 'challenge response exn message as signed", "description": "Mark challenge ' - 'response exn message as signed", "tags": ["Challenge/Response"], ' - '"parameters": [{"in": "path", "name": "name", "schema": {"type": ' - '"string"}, "required": true, "description": "Human readable alias for the ' - 'identifier to create"}], "requestBody": {"required": true, "content": {' - '"application/json": {"schema": {"description": "Challenge response", ' - '"properties": {"aid": {"type": "string", "description": "aid of signer of ' - 'accepted challenge response"}, "said": {"type": "array", "description": ' - '"SAID of challenge message signed", "items": {"type": "string"}}}}}}}, ' - '"responses": {"202": {"description": "Success submission of signed ' - 'challenge/response"}}}}, "/oobi/{aid}/{role}/{eid}": {"get": {}}, ' - '"/identifiers/{name}/endroles/{role}/{eid}": {"delete": {}}}, "info": {' - '"title": "KERIA Interactive Web Interface API", "version": "1.0.1"}, ' - '"openapi": "3.1.0"}') + assert js == """{"paths": {"/operations": {"get": {"summary": "Get list of long running operations", "parameters": [{"in": "query", "name": "type", "schema": {"type": "string"}, "required": false, "description": "filter list of long running operations by type"}], "responses": {"200": {"content": {"application/json": {"schema": {"type": "array"}}}}}}}, "/oobis": {"post": {"summary": "Resolve OOBI and assign an alias for the remote identifier", "description": "Resolve OOBI URL or `rpy` message by process results of request and assign 'alias' in contact data for resolved identifier", "tags": ["OOBIs"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"description": "OOBI", "properties": {"oobialias": {"type": "string", "description": "alias to assign to the identifier resolved from this OOBI", "required": false}, "url": {"type": "string", "description": "URL OOBI"}, "rpy": {"type": "object", "description": "unsigned KERI `rpy` event message with endpoints"}}}}}}, "responses": {"202": {"description": "OOBI resolution to key state successful"}}}}, "/states": {"get": {"summary": "Display key event log (KEL) for given identifier prefix", "description": "If provided qb64 identifier prefix is in Kevers, return the current state of the identifier along with the KEL and all associated signatures and receipts", "tags": ["Key Event Log"], "parameters": [{"in": "path", "name": "prefix", "schema": {"type": "string"}, "required": true, "description": "qb64 identifier prefix of KEL to load"}], "responses": {"200": {"description": "Key event log and key state of identifier"}, "404": {"description": "Identifier not found in Key event database"}}}}, "/events": {"get": {"summary": "Display key event log (KEL) for given identifier prefix", "description": "If provided qb64 identifier prefix is in Kevers, return the current state of the identifier along with the KEL and all associated signatures and receipts", "tags": ["Key Event Log"], "parameters": [{"in": "path", "name": "prefix", "schema": {"type": "string"}, "required": true, "description": "qb64 identifier prefix of KEL to load"}], "responses": {"200": {"description": "Key event log and key state of identifier"}, "404": {"description": "Identifier not found in Key event database"}}}}, "/queries": {"post": {"summary": "Display key event log (KEL) for given identifier prefix", "description": "If provided qb64 identifier prefix is in Kevers, return the current state of the identifier along with the KEL and all associated signatures and receipts", "tags": ["Query"], "parameters": [{"in": "body", "name": "pre", "schema": {"type": "string"}, "required": true, "description": "qb64 identifier prefix of KEL to load"}], "responses": {"200": {"description": "Key event log and key state of identifier"}, "404": {"description": "Identifier not found in Key event database"}}}}, "/identifiers": {"get": {}, "options": {}, "post": {}}, "/challenges": {"get": {"summary": "Get random list of words for a 2 factor auth challenge", "description": "Get the list of identifiers associated with this agent", "tags": ["Challenge/Response"], "parameters": [{"in": "query", "name": "strength", "schema": {"type": "int"}, "description": "cryptographic strength of word list", "required": false}], "responses": {"200": {"description": "An array of random words", "content": {"application/json": {"schema": {"description": "Random word list", "type": "object", "properties": {"words": {"type": "array", "description": "random challenge word list", "items": {"type": "string"}}}}}}}}}}, "/contacts": {"get": {"summary": "Get list of contact information associated with remote identifiers", "description": "Get list of contact information associated with remote identifiers. All information is metadata and kept in local storage only", "tags": ["Contacts"], "parameters": [{"in": "query", "name": "group", "schema": {"type": "string"}, "required": false, "description": "field name to group results by"}, {"in": "query", "name": "filter_field", "schema": {"type": "string"}, "description": "field name to search", "required": false}, {"in": "query", "name": "filter_value", "schema": {"type": "string"}, "description": "value to search for", "required": false}], "responses": {"200": {"description": "List of contact information for remote identifiers"}}}}, "/notifications": {"get": {"summary": "Get list of notifications for the controller of the agent", "description": "Get list of notifications for the controller of the agent. Notifications will be sorted by creation date/time", "parameters": [{"in": "header", "name": "Range", "schema": {"type": "string"}, "required": false, "description": "size of the result list. Defaults to 25"}], "tags": ["Notifications"], "responses": {"200": {"description": "List of contact information for remote identifiers"}}}}, "/oobi": {"get": {}}, "/": {"post": {"summary": "Accept KERI events with attachment headers and parse", "description": "Accept KERI events with attachment headers and parse.", "tags": ["Events"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "description": "KERI event message"}}}}, "responses": {"204": {"description": "KEL EXN, QRY, RPY event accepted."}}}, "put": {"summary": "Accept KERI events with attachment headers and parse", "description": "Accept KERI events with attachment headers and parse.", "tags": ["Events"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "description": "KERI event message"}}}}, "responses": {"200": {"description": "Mailbox query response for server sent events"}, "204": {"description": "KEL or EXN event accepted."}}}}, "/operations/{name}": {"delete": {}, "get": {}}, "/oobis/{alias}": {"get": {"summary": "Get OOBI for specific identifier", "description": "Generate OOBI for the identifier of the specified alias and role", "tags": ["OOBIs"], "parameters": [{"in": "path", "name": "alias", "schema": {"type": "string"}, "required": true, "description": "human readable alias for the identifier generate OOBI for"}, {"in": "query", "name": "role", "schema": {"type": "string"}, "required": true, "description": "role for which to generate OOBI"}], "responses": {"200": {"description": "An array of Identifier key state information", "content": {"application/json": {"schema": {"description": "Key state information for current identifiers", "type": "object"}}}}}}}, "/agent/{caid}": {"get": {}, "put": {}}, "/identifiers/{name}": {"get": {}, "put": {}}, "/endroles/{aid}": {"get": {}, "post": {}}, "/escrows/rpy": {"get": {}}, "/challenges/{name}": {"post": {"summary": "Sign challenge message and forward to peer identifier", "description": "Sign a challenge word list received out of bands and send `exn` peer to peer message to recipient", "tags": ["Challenge/Response"], "parameters": [{"in": "path", "name": "name", "schema": {"type": "string"}, "required": true, "description": "Human readable alias for the identifier to create"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"description": "Challenge response", "properties": {"recipient": {"type": "string", "description": "human readable alias recipient identifier to send signed challenge to"}, "words": {"type": "array", "description": "challenge in form of word list", "items": {"type": "string"}}}}}}}, "responses": {"202": {"description": "Success submission of signed challenge/response"}}}}, "/contacts/{prefix}": {"delete": {"summary": "Delete contact information associated with remote identifier", "description": "Delete contact information associated with remote identifier", "tags": ["Contacts"], "parameters": [{"in": "path", "name": "prefix", "schema": {"type": "string"}, "required": true, "description": "qb64 identifier prefix of contact to delete"}], "responses": {"202": {"description": "Contact information successfully deleted for prefix"}, "404": {"description": "No contact information found for prefix"}}}, "get": {"summary": "Get contact information associated with single remote identifier", "description": "Get contact information associated with single remote identifier. All information is meta-data and kept in local storage only", "tags": ["Contacts"], "parameters": [{"in": "path", "name": "prefix", "schema": {"type": "string"}, "required": true, "description": "qb64 identifier prefix of contact to get"}], "responses": {"200": {"description": "Contact information successfully retrieved for prefix"}, "404": {"description": "No contact information found for prefix"}}}, "post": {"summary": "Create new contact information for an identifier", "description": "Creates new information for an identifier, overwriting all existing information for that identifier", "tags": ["Contacts"], "parameters": [{"in": "path", "name": "prefix", "schema": {"type": "string"}, "required": true, "description": "qb64 identifier prefix to add contact metadata to"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"description": "Contact information", "type": "object"}}}}, "responses": {"200": {"description": "Updated contact information for remote identifier"}, "400": {"description": "Invalid identifier used to update contact information"}, "404": {"description": "Prefix not found in identifier contact information"}}}, "put": {"summary": "Update provided fields in contact information associated with remote identifier prefix", "description": "Update provided fields in contact information associated with remote identifier prefix. All information is metadata and kept in local storage only", "tags": ["Contacts"], "parameters": [{"in": "path", "name": "prefix", "schema": {"type": "string"}, "required": true, "description": "qb64 identifier prefix to add contact metadata to"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"description": "Contact information", "type": "object"}}}}, "responses": {"200": {"description": "Updated contact information for remote identifier"}, "400": {"description": "Invalid identifier used to update contact information"}, "404": {"description": "Prefix not found in identifier contact information"}}}}, "/notifications/{said}": {"delete": {"summary": "Delete notification", "description": "Delete notification", "tags": ["Notifications"], "parameters": [{"in": "path", "name": "said", "schema": {"type": "string"}, "required": true, "description": "qb64 said of note to delete"}], "responses": {"202": {"description": "Notification successfully deleted for prefix"}, "404": {"description": "No notification information found for prefix"}}}, "put": {"summary": "Mark notification as read", "description": "Mark notification as read", "tags": ["Notifications"], "parameters": [{"in": "path", "name": "said", "schema": {"type": "string"}, "required": true, "description": "qb64 said of note to mark as read"}], "responses": {"202": {"description": "Notification successfully marked as read for prefix"}, "404": {"description": "No notification information found for SAID"}}}}, "/oobi/{aid}": {"get": {}}, "/identifiers/{name}/oobis": {"get": {}}, "/identifiers/{name}/endroles": {"get": {}, "post": {}}, "/identifiers/{name}/members": {"get": {}}, "/endroles/{aid}/{role}": {"get": {}, "post": {}}, "/contacts/{prefix}/img": {"get": {"summary": "Get contact image for identifer prefix", "description": "Get contact image for identifer prefix", "tags": ["Contacts"], "parameters": [{"in": "path", "name": "prefix", "schema": {"type": "string"}, "required": true, "description": "qb64 identifier prefix of contact image to get"}], "responses": {"200": {"description": "Contact information successfully retrieved for prefix", "content": {"image/jpg": {"schema": {"description": "Image", "type": "binary"}}}}, "404": {"description": "No contact information found for prefix"}}}, "post": {"summary": "Uploads an image to associate with identifier.", "description": "Uploads an image to associate with identifier.", "tags": ["Contacts"], "parameters": [{"in": "path", "name": "prefix", "schema": {"type": "string"}, "description": "identifier prefix to associate image to", "required": true}], "requestBody": {"required": true, "content": {"image/jpg": {"schema": {"type": "string", "format": "binary"}}, "image/png": {"schema": {"type": "string", "format": "binary"}}}}, "responses": {"200": {"description": "Image successfully uploaded"}}}}, "/oobi/{aid}/{role}": {"get": {}}, "/identifiers/{name}/endroles/{role}": {"get": {}, "post": {}}, "/challenges/{name}/verify/{source}": {"post": {"summary": "Sign challenge message and forward to peer identifier", "description": "Sign a challenge word list received out of bands and send `exn` peer to peer message to recipient", "tags": ["Challenge/Response"], "parameters": [{"in": "path", "name": "name", "schema": {"type": "string"}, "required": true, "description": "Human readable alias for the identifier to create"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"description": "Challenge response", "properties": {"recipient": {"type": "string", "description": "human readable alias recipient identifier to send signed challenge to"}, "words": {"type": "array", "description": "challenge in form of word list", "items": {"type": "string"}}}}}}}, "responses": {"202": {"description": "Success submission of signed challenge/response"}}}, "put": {"summary": "Mark challenge response exn message as signed", "description": "Mark challenge response exn message as signed", "tags": ["Challenge/Response"], "parameters": [{"in": "path", "name": "name", "schema": {"type": "string"}, "required": true, "description": "Human readable alias for the identifier to create"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"description": "Challenge response", "properties": {"aid": {"type": "string", "description": "aid of signer of accepted challenge response"}, "said": {"type": "array", "description": "SAID of challenge message signed", "items": {"type": "string"}}}}}}}, "responses": {"202": {"description": "Success submission of signed challenge/response"}}}}, "/oobi/{aid}/{role}/{eid}": {"get": {}}, "/identifiers/{name}/endroles/{role}/{eid}": {"delete": {}}}, "info": {"title": "KERIA Interactive Web Interface API", "version": "1.0.1"}, "openapi": "3.1.0"}""" diff --git a/tests/core/test_longrunning.py b/tests/core/test_longrunning.py new file mode 100644 index 00000000..cd387184 --- /dev/null +++ b/tests/core/test_longrunning.py @@ -0,0 +1,186 @@ +from keria.app import aiding +from keri.kering import ValidationError +from keria.core import longrunning + + +def test_operations(helpers): + with helpers.openKeria() as (agency, agent, app, client): + + end = aiding.IdentifierCollectionEnd() + app.add_route("/identifiers", end) + endRolesEnd = aiding.EndRoleCollectionEnd() + app.add_route("/identifiers/{name}/endroles", endRolesEnd) + opColEnd = longrunning.OperationCollectionEnd() + app.add_route("/operations", opColEnd) + opResEnd = longrunning.OperationResourceEnd() + app.add_route("/operations/{name}", opResEnd) + + # operations is empty + + res = client.simulate_get(path="/operations") + assert isinstance(res.json, list) + assert len(res.json) == 0 + + res = client.simulate_get(path="/operations?type=endrole") + assert isinstance(res.json, list) + assert len(res.json) == 0 + + # create aid + + salt = b"C6X8UfJqYrOmJQHKqnI5a" + op = helpers.createAid(client, "user1", salt) + assert op["done"] == True + assert op["name"] == "done.EAF7geUfHm-M5lA-PI6Jv-4708a-KknnlMlA7U1_Wduv" + aid = op["response"] + recp = aid['i'] + assert recp == "EAF7geUfHm-M5lA-PI6Jv-4708a-KknnlMlA7U1_Wduv" + + # operations has 1 element + + res = client.simulate_get(path="/operations") + assert isinstance(res.json, list) + assert len(res.json) == 1 + r = next(filter(lambda i: i["name"] == op["name"], res.json), None) + assert r == op + + res = client.simulate_get(path="/operations?type=endrole") + assert isinstance(res.json, list) + assert len(res.json) == 0 + r = next(filter(lambda i: i["name"] == op["name"], res.json), None) + assert r == None + + # add endrole + + rpy = helpers.endrole(recp, agent.agentHab.pre) + sigs = helpers.sign(salt, 0, 0, rpy.raw) + body = dict(rpy=rpy.ked, sigs=sigs) + res = client.simulate_post( + path=f"/identifiers/user1/endroles", json=body) + op = res.json + assert op["done"] == True + assert op["name"] == "endrole.EAF7geUfHm-M5lA-PI6Jv-4708a-KknnlMlA7U1_Wduv.agent.EI7AkI40M11MS7lkTCb10JC9-nDt-tXwQh44OHAFlv_9" + + # operations has 2 elements + + res = client.simulate_get(path="/operations") + assert isinstance(res.json, list) + assert len(res.json) == 2 + r = next(filter(lambda i: i["name"] == op["name"], res.json), None) + assert r == op + + res = client.simulate_get(path="/operations?type=endrole") + assert isinstance(res.json, list) + assert len(res.json) == 1 + r = next(filter(lambda i: i["name"] == op["name"], res.json), None) + assert r == op + + # create aid + + salt = b"tRkaivxZkQPfqjlDY6j1K" + op = helpers.createAid(client, "user2", salt) + assert op["done"] == True + assert op["name"] == "done.EAyXphfc0qOLqEDAe0cCYCj-ovbSaEFgVgX6MrC_b5ZO" + aid = op["response"] + recp = aid['i'] + assert recp == "EAyXphfc0qOLqEDAe0cCYCj-ovbSaEFgVgX6MrC_b5ZO" + + # operations has 3 elements + + res = client.simulate_get(path="/operations") + assert isinstance(res.json, list) + assert len(res.json) == 3 + r = next(filter(lambda i: i["name"] == op["name"], res.json), None) + assert r == op + + res = client.simulate_get(path="/operations?type=endrole") + assert isinstance(res.json, list) + assert len(res.json) == 1 + r = next(filter(lambda i: i["name"] == op["name"], res.json), None) + assert r == None + + # add endrole + + rpy = helpers.endrole(recp, agent.agentHab.pre) + sigs = helpers.sign(salt, 0, 0, rpy.raw) + body = dict(rpy=rpy.ked, sigs=sigs) + res = client.simulate_post( + path=f"/identifiers/user2/endroles", json=body) + op = res.json + assert op["done"] == True + assert op["name"] == "endrole.EAyXphfc0qOLqEDAe0cCYCj-ovbSaEFgVgX6MrC_b5ZO.agent.EI7AkI40M11MS7lkTCb10JC9-nDt-tXwQh44OHAFlv_9" + + # operations has 4 elements + + res = client.simulate_get(path="/operations") + assert isinstance(res.json, list) + assert len(res.json) == 4 + r = next(filter(lambda i: i["name"] == op["name"], res.json), None) + assert r == op + + res = client.simulate_get(path="/operations?type=endrole") + assert isinstance(res.json, list) + assert len(res.json) == 2 + r = next(filter(lambda i: i["name"] == op["name"], res.json), None) + assert r == op + + # GET /operations returns same as each GET /operations/{name} + + res = client.simulate_get(path="/operations") + for i in res.json: + t = client.simulate_get(path=f"/operations/{i['name']}") + assert i == t.json + + # delete by type + + res = client.simulate_get(path="/operations?type=endrole") + for i in res.json: + t = client.simulate_delete(path=f"/operations/{i['name']}") + assert t.status_code == 204 + + # operations has 2 remaining + + res = client.simulate_get(path="/operations") + assert isinstance(res.json, list) + assert len(res.json) == 2 + + # delete remaining + + res = client.simulate_get(path="/operations") + for i in res.json: + t = client.simulate_delete(path=f"/operations/{i['name']}") + assert t.status_code == 204 + + # operations has 0 remaining + + res = client.simulate_get(path="/operations") + assert isinstance(res.json, list) + assert len(res.json) == 0 + + +def test_error(helpers): + with helpers.openKeria() as (agency, agent, app, client): + + opColEnd = longrunning.OperationCollectionEnd() + app.add_route("/operations", opColEnd) + opResEnd = longrunning.OperationResourceEnd() + app.add_route("/operations/{name}", opResEnd) + + err = None + try: + # submitting an invalid non-existing witness operation to mock error condition + agent.monitor.submit( + "EBfdlu8R27Fbx-ehrqwImnK-8Cm79sqbAQ4MmvEAYqao", longrunning.OpTypes.witness, dict()) + except ValidationError as e: + err = e + + res = client.simulate_get(path="/operations") + assert isinstance(res.json, list) + assert len(res.json) == 1 + + op = res.json[0] + assert op["done"] == True + assert op["error"]["code"] == 500 + assert op["error"]["message"] == f"{err}" + + res = client.simulate_get(path=f"/operations/{op['name']}") + assert res.status_code == 500