From 03f6ef54c604eb02208aed43d4a39768e3e3153d Mon Sep 17 00:00:00 2001 From: Stavros-Anastasios Charismiadis Date: Wed, 5 Aug 2026 11:33:19 +0300 Subject: [PATCH 1/3] Add interconnection procedure in DELETE method of Publish API --- .../published_apis/core/auth_manager.py | 4 + .../core/serviceapidescriptions.py | 105 +++++++++++++++++- 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/auth_manager.py b/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/auth_manager.py index 0e0d7f1..e331313 100644 --- a/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/auth_manager.py +++ b/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/auth_manager.py @@ -27,6 +27,10 @@ class AuthManager(Resource): auth_context = cert_col.find_one({"id":apf_id}) + # Service APIs published by an interconnected CCF have no certificate entry + if auth_context is None: + return + if "services" in auth_context["resources"]: if service_id in auth_context["resources"]["services"]: auth_context["resources"]["services"].remove(service_id) diff --git a/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py b/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py index 45486df..9eb9b0e 100644 --- a/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py +++ b/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py @@ -22,6 +22,9 @@ import encoder TOTAL_FEATURES = 10 SUPPORTED_FEATURES_HEX = "120" +# Seconds allowed for a single request towards a peer CCF +INTERCONNECTION_TIMEOUT = 30 + publisher_ops = Publisher() @@ -189,10 +192,10 @@ class PublishServiceOperations(Resource): ) # 1. Check shareableInfo - if serviceapidescription.shareable_info.is_shareable: + if serviceapidescription.shareable_info and serviceapidescription.shareable_info.is_shareable: interconnected_col = self.db.get_col_by_name(self.db.interconnected) # 2. Iterate through capifProvDoms - for dom in serviceapidescription.shareable_info.capif_prov_doms: + for dom in serviceapidescription.shareable_info.capif_prov_doms or []: # 3. For each domain, check if there is record in the "interconnected" table interconnected_ccf = interconnected_col.find_one({"dst_prov_dom": dom}) # 4. If yes, make a publish request with CCF certificate @@ -298,6 +301,90 @@ class PublishServiceOperations(Resource): current_app.logger.error(exception + "::" + str(e)) return internal_server_error(detail=exception, cause=str(e)) + def unpublish_from_interconnected_ccfs(self, api_name, capif_prov_doms): + """Unpublish a shared service API from every interconnected CCF holding a copy. + + A peer assigns its own api_id to the copy it stores, and that id is never kept + locally, so it is resolved by api_name out of the service APIs this CCF published + there. Returns an error response if a peer could not be reached or refused the + removal, None if every copy is gone. + """ + interconnected_col = self.db.get_col_by_name(self.db.interconnected) + config_col = self.db.get_col_by_name(self.db.capif_configuration) + config = config_col.find_one({}, {"_id": 0}) + ccf_id = config['ccf_id'] + + headers = {'accept': 'application/json'} + certs = ('certs/server.crt', 'certs/server.key') + + for dom in capif_prov_doms: + interconnected_ccf = interconnected_col.find_one({"dst_prov_dom": dom}) + if interconnected_ccf is None: + continue + + url = 'https://{}/published-apis/v1/{}/service-apis'.format(dom, ccf_id) + + try: + response = requests.request("GET", url, headers=headers, cert=certs, + verify='certs/ca.crt', + timeout=INTERCONNECTION_TIMEOUT) + except requests.exceptions.RequestException as exc: + current_app.logger.error( + "Interconnection: listing service apis on {} failed: {}".format(dom, str(exc))) + return internal_server_error( + detail="Could not reach interconnected CCF {}".format(dom), + cause="Service API is still published on an interconnected CCF") + + try: + shared_apis = response.json() + except ValueError: + shared_apis = None + + if response.status_code != 200 or not isinstance(shared_apis, list): + current_app.logger.error( + "Interconnection: {} answered the service api listing with status {}".format( + dom, response.status_code)) + return internal_server_error( + detail="Interconnected CCF {} did not return its published service apis".format(dom), + cause="Service API is still published on an interconnected CCF") + + # The listing is serialized in camel case by the peer + shared_api_id = next( + (shared_api.get("apiId") for shared_api in shared_apis + if isinstance(shared_api, dict) and shared_api.get("apiName") == api_name), + None) + + if shared_api_id is None: + current_app.logger.warning( + "Interconnection: service api {} is not published on {}".format(api_name, dom)) + continue + + try: + response = requests.request("DELETE", '{}/{}'.format(url, shared_api_id), + headers=headers, cert=certs, + verify='certs/ca.crt', + timeout=INTERCONNECTION_TIMEOUT) + except requests.exceptions.RequestException as exc: + current_app.logger.error( + "Interconnection: unpublish from {} failed: {}".format(dom, str(exc))) + return internal_server_error( + detail="Could not reach interconnected CCF {}".format(dom), + cause="Service API is still published on an interconnected CCF") + + # 404 means the peer already dropped its copy + if response.status_code not in (204, 404): + current_app.logger.error( + "Interconnection: {} refused to unpublish service api {} with status {}".format( + dom, shared_api_id, response.status_code)) + return internal_server_error( + detail="Interconnected CCF {} refused to unpublish the service api".format(dom), + cause="Service API is still published on an interconnected CCF") + + current_app.logger.debug( + "Interconnection: service api {} unpublished from {}".format(api_name, dom)) + + return None + def delete_serviceapidescription(self, service_api_id, apf_id): mycol = self.db.get_col_by_name(self.db.service_api_descriptions) @@ -327,9 +414,21 @@ class PublishServiceOperations(Resource): detail="Service API not existing", cause="Service API id not found") + # The copies held by interconnected CCFs go first, so that a peer that cannot + # be reached leaves the service api published here instead of orphaned there + shareable_info = serviceapidescription_dict.get("shareable_info") or {} + if shareable_info.get("is_shareable"): + interconnection_error = self.unpublish_from_interconnected_ccfs( + serviceapidescription_dict.get("api_name"), + shareable_info.get("capif_prov_doms") or []) + + if interconnection_error is not None: + return interconnection_error + mycol.delete_one(my_query) - self.auth_manager.remove_auth_service(service_api_id, apf_id) + if "APF" in apf_id: + self.auth_manager.remove_auth_service(service_api_id, apf_id) current_app.logger.info("Removed service from database") out = "The service matching api_id " + service_api_id + " was deleted." -- GitLab From 4eeb447114883d844d6b50dd9b954ec007dcd09a Mon Sep 17 00:00:00 2001 From: Stavros-Anastasios Charismiadis Date: Wed, 5 Aug 2026 14:12:54 +0300 Subject: [PATCH 2/3] Implement interconneciton procedures for PUT and PATCH of Publish API --- .../core/serviceapidescriptions.py | 400 ++++++++++++++---- 1 file changed, 309 insertions(+), 91 deletions(-) diff --git a/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py b/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py index 9eb9b0e..c39ce85 100644 --- a/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py +++ b/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py @@ -24,6 +24,8 @@ SUPPORTED_FEATURES_HEX = "120" # Seconds allowed for a single request towards a peer CCF INTERCONNECTION_TIMEOUT = 30 +INTERCONNECTION_CERT = ('certs/server.crt', 'certs/server.key') +INTERCONNECTION_CA = 'certs/ca.crt' publisher_ops = Publisher() @@ -56,6 +58,28 @@ def find_duplicate_service_by_api_name_and_aef( return collection.find_one(duplicate_query), aef_ids +def shared_capif_prov_doms(shareable_info): + """Return the CAPIF provider domains a service API is meant to be shared with.""" + shareable_info = shareable_info or {} + + if not shareable_info.get("is_shareable"): + return [] + + return shareable_info.get("capif_prov_doms") or [] + + +def interconnection_payload(service_api): + """Serialize a service API the way a peer CCF expects to receive it.""" + payload = { + key: value for key, value in service_api.items() + if key not in ("_id", "apf_id", "onboarding_date") + } + # The peer stores the service API as its own, it must not share it any further + payload["shareable_info"] = {"is_shareable": False} + + return json.dumps(clean_n_camel_case(payload), cls=encoder.CustomJSONEncoder) + + def return_negotiated_supp_feat_dict(supp_feat): final_supp_feat = bin(int(supp_feat, 16) & int(SUPPORTED_FEATURES_HEX, 16))[2:].zfill(TOTAL_FEATURES)[::-1] @@ -191,46 +215,25 @@ class PublishServiceOperations(Resource): invalid_params=[{"param": "apiStatus", "reason": "defined but apiStatusMonitoring feature not active"}] ) - # 1. Check shareableInfo - if serviceapidescription.shareable_info and serviceapidescription.shareable_info.is_shareable: - interconnected_col = self.db.get_col_by_name(self.db.interconnected) - # 2. Iterate through capifProvDoms - for dom in serviceapidescription.shareable_info.capif_prov_doms or []: - # 3. For each domain, check if there is record in the "interconnected" table - interconnected_ccf = interconnected_col.find_one({"dst_prov_dom": dom}) - # 4. If yes, make a publish request with CCF certificate - if interconnected_ccf: - config_col = self.db.get_col_by_name(self.db.capif_configuration) - config = config_col.find_one({}, {"_id": 0}) - ccf_id = config['ccf_id'] - serviceapidescription_dict['shareable_info'] = {"is_shareable": False} - - url = 'https://{}/published-apis/v1/{}/service-apis'.format(dom, ccf_id) - - headers = { - 'accept': 'application/json', - 'Content-Type': 'application/json' - } - - response = requests.request("POST", url, headers=headers, data=json.dumps(clean_n_camel_case(serviceapidescription_dict), cls=encoder.CustomJSONEncoder), cert=('certs/server.crt', 'certs/server.key'), verify='certs/ca.crt') - - if (response.status_code == 201 or response.status_code == 200): - if rec.get("pub_api_path") is None: - rec.update({"pub_api_path": {"ccf_ids": [interconnected_ccf.get("ccf_id")]}}) - else: - ccf_list = rec.get("pub_api_path").get("ccf_ids") - ccf_list.append(interconnected_ccf.get("ccf_id")) - rec.update({"pub_api_path": {"ccf_ids": ccf_list}}) - else: - rec['shareable_info']['capif_prov_doms'].remove(dom) + published, interconnection_error = self.publish_to_interconnected_ccfs( + rec, shared_capif_prov_doms(rec.get("shareable_info"))) + + if interconnection_error is not None: + # Nothing is stored here, so a copy left on a peer would be an orphan and + # would make every later attempt fail as a duplicate + self.withdraw_published_copies(rec.get("api_name"), published) + return interconnection_error + + reached_ccf_ids = [ccf_id for _, ccf_id in published] + # A publication coming from a peer CCF extends the path it travelled if "CCF" in apf_id: - if rec.get("pub_api_path") is None: - rec.update({"pub_api_path": {"ccf_ids": [apf_id]}}) - else: - ccf_list = rec.get("pub_api_path").get("ccf_ids") - ccf_list.append(apf_id) - rec.update({"pub_api_path": {"ccf_ids": ccf_list}}) + reached_ccf_ids.append(apf_id) + + if reached_ccf_ids: + ccf_ids = list((rec.get("pub_api_path") or {}).get("ccf_ids") or []) + ccf_ids.extend(ccf_id for ccf_id in reached_ccf_ids if ccf_id not in ccf_ids) + rec["pub_api_path"] = {"ccf_ids": ccf_ids} mycol.insert_one(rec) @@ -301,90 +304,279 @@ class PublishServiceOperations(Resource): current_app.logger.error(exception + "::" + str(e)) return internal_server_error(detail=exception, cause=str(e)) + def interconnection_service_apis_url(self, dom): + """Endpoint holding the service APIs this CCF published on the given domain.""" + config_col = self.db.get_col_by_name(self.db.capif_configuration) + config = config_col.find_one({}, {"_id": 0}) + + return 'https://{}/published-apis/v1/{}/service-apis'.format(dom, config['ccf_id']) + + def find_shared_service_api_id(self, dom, api_name, cause): + """Resolve the api_id a peer CCF assigned to its copy of a service API. + + A peer assigns its own api_id to the copy it stores and that id is never kept + locally, so it is looked up by api_name out of the service APIs this CCF published + there. Returns None when the peer holds no copy, plus an error response when the + peer could not be asked. + """ + url = self.interconnection_service_apis_url(dom) + + try: + response = requests.request("GET", url, headers={'accept': 'application/json'}, + cert=INTERCONNECTION_CERT, verify=INTERCONNECTION_CA, + timeout=INTERCONNECTION_TIMEOUT) + except requests.exceptions.RequestException as exc: + current_app.logger.error( + "Interconnection: listing service apis on {} failed: {}".format(dom, str(exc))) + return None, internal_server_error( + detail="Could not reach interconnected CCF {}".format(dom), cause=cause) + + try: + shared_apis = response.json() + except ValueError: + shared_apis = None + + if response.status_code != 200 or not isinstance(shared_apis, list): + current_app.logger.error( + "Interconnection: {} answered the service api listing with status {}".format( + dom, response.status_code)) + return None, internal_server_error( + detail="Interconnected CCF {} did not return its published service apis".format(dom), + cause=cause) + + # The listing is serialized in camel case by the peer + shared_api_id = next( + (shared_api.get("apiId") for shared_api in shared_apis + if isinstance(shared_api, dict) and shared_api.get("apiName") == api_name), + None) + + return shared_api_id, None + def unpublish_from_interconnected_ccfs(self, api_name, capif_prov_doms): """Unpublish a shared service API from every interconnected CCF holding a copy. - A peer assigns its own api_id to the copy it stores, and that id is never kept - locally, so it is resolved by api_name out of the service APIs this CCF published - there. Returns an error response if a peer could not be reached or refused the - removal, None if every copy is gone. + Returns the ids of the CCFs that no longer hold a copy, and an error response if a + peer could not be reached or refused the removal. """ - interconnected_col = self.db.get_col_by_name(self.db.interconnected) - config_col = self.db.get_col_by_name(self.db.capif_configuration) - config = config_col.find_one({}, {"_id": 0}) - ccf_id = config['ccf_id'] + if not capif_prov_doms: + return [], None - headers = {'accept': 'application/json'} - certs = ('certs/server.crt', 'certs/server.key') + interconnected_col = self.db.get_col_by_name(self.db.interconnected) + cause = "Service API is still published on an interconnected CCF" + unpublished_ccf_ids = [] for dom in capif_prov_doms: interconnected_ccf = interconnected_col.find_one({"dst_prov_dom": dom}) if interconnected_ccf is None: continue - url = 'https://{}/published-apis/v1/{}/service-apis'.format(dom, ccf_id) + shared_api_id, error = self.find_shared_service_api_id(dom, api_name, cause) + if error is not None: + return unpublished_ccf_ids, error + + if shared_api_id is None: + current_app.logger.warning( + "Interconnection: service api {} is not published on {}".format(api_name, dom)) + unpublished_ccf_ids.append(interconnected_ccf.get("ccf_id")) + continue + + url = '{}/{}'.format(self.interconnection_service_apis_url(dom), shared_api_id) try: - response = requests.request("GET", url, headers=headers, cert=certs, - verify='certs/ca.crt', + response = requests.request("DELETE", url, + headers={'accept': 'application/json'}, + cert=INTERCONNECTION_CERT, verify=INTERCONNECTION_CA, timeout=INTERCONNECTION_TIMEOUT) except requests.exceptions.RequestException as exc: current_app.logger.error( - "Interconnection: listing service apis on {} failed: {}".format(dom, str(exc))) - return internal_server_error( - detail="Could not reach interconnected CCF {}".format(dom), - cause="Service API is still published on an interconnected CCF") - - try: - shared_apis = response.json() - except ValueError: - shared_apis = None + "Interconnection: unpublish from {} failed: {}".format(dom, str(exc))) + return unpublished_ccf_ids, internal_server_error( + detail="Could not reach interconnected CCF {}".format(dom), cause=cause) - if response.status_code != 200 or not isinstance(shared_apis, list): + # 404 means the peer already dropped its copy + if response.status_code not in (204, 404): current_app.logger.error( - "Interconnection: {} answered the service api listing with status {}".format( - dom, response.status_code)) - return internal_server_error( - detail="Interconnected CCF {} did not return its published service apis".format(dom), - cause="Service API is still published on an interconnected CCF") + "Interconnection: {} refused to unpublish service api {} with status {}".format( + dom, shared_api_id, response.status_code)) + return unpublished_ccf_ids, internal_server_error( + detail="Interconnected CCF {} refused to unpublish the service api".format(dom), + cause=cause) + + unpublished_ccf_ids.append(interconnected_ccf.get("ccf_id")) + current_app.logger.debug( + "Interconnection: service api {} unpublished from {}".format(api_name, dom)) + + return unpublished_ccf_ids, None + + def update_on_interconnected_ccfs(self, shared_api_name, service_api, capif_prov_doms): + """Push the current definition of a service API to the CCFs already holding a copy. - # The listing is serialized in camel case by the peer - shared_api_id = next( - (shared_api.get("apiId") for shared_api in shared_apis - if isinstance(shared_api, dict) and shared_api.get("apiName") == api_name), - None) + The copies are looked up under the name they were shared with, which is the one + stored before this modification. Returns an error response if a peer could not be + reached or refused the update. + """ + if not capif_prov_doms: + return None + + interconnected_col = self.db.get_col_by_name(self.db.interconnected) + cause = "Service API could not be updated on an interconnected CCF" + headers = { + 'accept': 'application/json', + 'Content-Type': 'application/json' + } + body = interconnection_payload(service_api) + + for dom in capif_prov_doms: + if interconnected_col.find_one({"dst_prov_dom": dom}) is None: + continue + + shared_api_id, error = self.find_shared_service_api_id(dom, shared_api_name, cause) + if error is not None: + return error if shared_api_id is None: current_app.logger.warning( - "Interconnection: service api {} is not published on {}".format(api_name, dom)) + "Interconnection: service api {} is not published on {}, update skipped".format( + shared_api_name, dom)) continue + url = '{}/{}'.format(self.interconnection_service_apis_url(dom), shared_api_id) + try: - response = requests.request("DELETE", '{}/{}'.format(url, shared_api_id), - headers=headers, cert=certs, - verify='certs/ca.crt', + response = requests.request("PUT", url, headers=headers, data=body, + cert=INTERCONNECTION_CERT, verify=INTERCONNECTION_CA, timeout=INTERCONNECTION_TIMEOUT) except requests.exceptions.RequestException as exc: current_app.logger.error( - "Interconnection: unpublish from {} failed: {}".format(dom, str(exc))) + "Interconnection: update on {} failed: {}".format(dom, str(exc))) return internal_server_error( - detail="Could not reach interconnected CCF {}".format(dom), - cause="Service API is still published on an interconnected CCF") + detail="Could not reach interconnected CCF {}".format(dom), cause=cause) - # 404 means the peer already dropped its copy - if response.status_code not in (204, 404): + if response.status_code not in (200, 204): current_app.logger.error( - "Interconnection: {} refused to unpublish service api {} with status {}".format( + "Interconnection: {} refused to update service api {} with status {}".format( dom, shared_api_id, response.status_code)) return internal_server_error( - detail="Interconnected CCF {} refused to unpublish the service api".format(dom), - cause="Service API is still published on an interconnected CCF") + detail="Interconnected CCF {} refused to update the service api".format(dom), + cause=cause) current_app.logger.debug( - "Interconnection: service api {} unpublished from {}".format(api_name, dom)) + "Interconnection: service api {} updated on {}".format(shared_api_name, dom)) return None + def publish_to_interconnected_ccfs(self, service_api, capif_prov_doms): + """Publish a shared service API to the interconnected CCF of every given domain. + + Returns the (domain, ccf_id) pairs that stored a copy, and an error response if a + peer could not be reached or refused the publication. The caller is expected to + withdraw the copies already accepted when an error comes back, as they would + otherwise be rejected as duplicates by a later attempt. + """ + if not capif_prov_doms: + return [], None + + interconnected_col = self.db.get_col_by_name(self.db.interconnected) + cause = "Service API could not be shared with an interconnected CCF" + headers = { + 'accept': 'application/json', + 'Content-Type': 'application/json' + } + body = interconnection_payload(service_api) + published = [] + + for dom in capif_prov_doms: + interconnected_ccf = interconnected_col.find_one({"dst_prov_dom": dom}) + if interconnected_ccf is None: + current_app.logger.warning( + "Interconnection: {} is not interconnected, publication skipped".format(dom)) + continue + + url = self.interconnection_service_apis_url(dom) + + try: + response = requests.request("POST", url, headers=headers, data=body, + cert=INTERCONNECTION_CERT, verify=INTERCONNECTION_CA, + timeout=INTERCONNECTION_TIMEOUT) + except requests.exceptions.RequestException as exc: + current_app.logger.error( + "Interconnection: publish to {} failed: {}".format(dom, str(exc))) + return published, internal_server_error( + detail="Could not reach interconnected CCF {}".format(dom), cause=cause) + + if response.status_code not in (200, 201): + current_app.logger.error( + "Interconnection: {} rejected service api {} with status {}".format( + dom, service_api.get("api_name"), response.status_code)) + return published, internal_server_error( + detail="Interconnected CCF {} rejected the service api".format(dom), + cause=cause) + + published.append((dom, interconnected_ccf.get("ccf_id"))) + current_app.logger.debug( + "Interconnection: service api {} published on {}".format( + service_api.get("api_name"), dom)) + + return published, None + + def withdraw_published_copies(self, api_name, published): + """Undo the publications made before an interconnection procedure failed.""" + _, error = self.unpublish_from_interconnected_ccfs( + api_name, [dom for dom, _ in published]) + + if error is not None: + current_app.logger.error( + "Interconnection: service api {} could not be withdrawn from the CCFs it " + "reached before the failure".format(api_name)) + + def reconcile_interconnection_sharing(self, service_api, old_service_api): + """Align the interconnected CCFs holding a copy with the modification requested. + + Domains dropped from the sharing list get the copy withdrawn, which covers every + domain at once when sharing is turned off. Domains added to it receive the service + API, which covers every domain at once when sharing is turned on. Domains kept on + the list get the copy they already hold updated. Returns the publication path to + store and an error response if any peer could not be aligned, in which case the + caller must leave the service API untouched. + """ + old_doms = shared_capif_prov_doms(old_service_api.get("shareable_info")) + new_doms = shared_capif_prov_doms(service_api.get("shareable_info")) + pub_api_path = old_service_api.get("pub_api_path") + + if not old_doms and not new_doms: + return pub_api_path, None + + withdrawn_doms = [dom for dom in old_doms if dom not in new_doms] + kept_doms = [dom for dom in new_doms if dom in old_doms] + added_doms = [dom for dom in new_doms if dom not in old_doms] + + # Peers hold the copy under the name it carried when it was shared with them, so + # a modification renaming the service API must still be matched against the old one + shared_api_name = old_service_api.get("api_name") + + unpublished_ccf_ids, error = self.unpublish_from_interconnected_ccfs( + shared_api_name, withdrawn_doms) + if error is not None: + return pub_api_path, error + + error = self.update_on_interconnected_ccfs(shared_api_name, service_api, kept_doms) + if error is not None: + return pub_api_path, error + + # A fresh publication carries the new name, so that is what the copies are known + # as on the peers that just accepted them + published, error = self.publish_to_interconnected_ccfs(service_api, added_doms) + if error is not None: + self.withdraw_published_copies(service_api.get("api_name"), published) + return pub_api_path, error + + ccf_ids = [ccf_id for ccf_id in (pub_api_path or {}).get("ccf_ids") or [] + if ccf_id not in unpublished_ccf_ids] + ccf_ids.extend(ccf_id for _, ccf_id in published if ccf_id not in ccf_ids) + + # ccf_ids must hold at least one entry, so the whole attribute goes once emptied + return ({"ccf_ids": ccf_ids} if ccf_ids else None), None + def delete_serviceapidescription(self, service_api_id, apf_id): mycol = self.db.get_col_by_name(self.db.service_api_descriptions) @@ -416,14 +608,12 @@ class PublishServiceOperations(Resource): # The copies held by interconnected CCFs go first, so that a peer that cannot # be reached leaves the service api published here instead of orphaned there - shareable_info = serviceapidescription_dict.get("shareable_info") or {} - if shareable_info.get("is_shareable"): - interconnection_error = self.unpublish_from_interconnected_ccfs( - serviceapidescription_dict.get("api_name"), - shareable_info.get("capif_prov_doms") or []) + _, interconnection_error = self.unpublish_from_interconnected_ccfs( + serviceapidescription_dict.get("api_name"), + shared_capif_prov_doms(serviceapidescription_dict.get("shareable_info"))) - if interconnection_error is not None: - return interconnection_error + if interconnection_error is not None: + return interconnection_error mycol.delete_one(my_query) @@ -524,6 +714,20 @@ class PublishServiceOperations(Resource): invalid_params=[{"param": "apiStatus", "reason": "defined but apiStatusMoniroting feature not active"}] ) + # The publication path records where the copies live, so this CCF owns it and + # the one held in the request is discarded + service_api_description["pub_api_path"] = serviceapidescription_old.get("pub_api_path") + + # The peers are aligned before the local write, so that one that cannot be + # reached leaves the sharing settings as they were instead of drifting apart + pub_api_path, interconnection_error = self.reconcile_interconnection_sharing( + service_api_description, serviceapidescription_old) + + if interconnection_error is not None: + return interconnection_error + + service_api_description["pub_api_path"] = pub_api_path + result = mycol.find_one_and_replace( serviceapidescription_old, service_api_description, @@ -636,6 +840,20 @@ class PublishServiceOperations(Resource): invalid_params=[{"param": "apiStatus", "reason": "defined but apiStatusMoniroting feature not active"}] ) + # A $set merges the patch into the stored service API, so the peers are + # aligned against that same result. Attributes the patch leaves out keep their + # stored value, which for the sharing settings means no peer is touched + patched_service_api = dict(serviceapidescription_old) + patched_service_api.update(patch_service_api_description) + + pub_api_path, interconnection_error = self.reconcile_interconnection_sharing( + patched_service_api, serviceapidescription_old) + + if interconnection_error is not None: + return interconnection_error + + patch_service_api_description["pub_api_path"] = pub_api_path + result = mycol.find_one_and_update( my_query, {"$set": patch_service_api_description}, -- GitLab From 8fc445d5eb2b26f1d363bd19b98ed736cfe88ad5 Mon Sep 17 00:00:00 2001 From: Stavros-Anastasios Charismiadis Date: Thu, 6 Aug 2026 11:02:46 +0300 Subject: [PATCH 3/3] Refine comments --- .../core/serviceapidescriptions.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py b/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py index c39ce85..ad14836 100644 --- a/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py +++ b/services/TS29222_CAPIF_Publish_Service_API/published_apis/core/serviceapidescriptions.py @@ -312,11 +312,11 @@ class PublishServiceOperations(Resource): return 'https://{}/published-apis/v1/{}/service-apis'.format(dom, config['ccf_id']) def find_shared_service_api_id(self, dom, api_name, cause): - """Resolve the api_id a peer CCF assigned to its copy of a service API. + """Resolve the api_id a peer CCF assigned to its shared service API. - A peer assigns its own api_id to the copy it stores and that id is never kept + A peer assigns its own api_id to the API it stores and that id is never kept locally, so it is looked up by api_name out of the service APIs this CCF published - there. Returns None when the peer holds no copy, plus an error response when the + there. Returns None when the peer has not the specific API shared, plus an error response when the peer could not be asked. """ url = self.interconnection_service_apis_url(dom) @@ -393,7 +393,7 @@ class PublishServiceOperations(Resource): return unpublished_ccf_ids, internal_server_error( detail="Could not reach interconnected CCF {}".format(dom), cause=cause) - # 404 means the peer already dropped its copy + # 404 means the peer already dropped the specific API if response.status_code not in (204, 404): current_app.logger.error( "Interconnection: {} refused to unpublish service api {} with status {}".format( @@ -409,7 +409,7 @@ class PublishServiceOperations(Resource): return unpublished_ccf_ids, None def update_on_interconnected_ccfs(self, shared_api_name, service_api, capif_prov_doms): - """Push the current definition of a service API to the CCFs already holding a copy. + """Push the current definition of a service API to the CCFs already holding the shared API. The copies are looked up under the name they were shared with, which is the one stored before this modification. Returns an error response if a peer could not be @@ -530,9 +530,9 @@ class PublishServiceOperations(Resource): "reached before the failure".format(api_name)) def reconcile_interconnection_sharing(self, service_api, old_service_api): - """Align the interconnected CCFs holding a copy with the modification requested. + """Align the interconnected CCFs holding the API with the modification requested. - Domains dropped from the sharing list get the copy withdrawn, which covers every + Domains dropped from the sharing list get the shared API withdrawn, which covers every domain at once when sharing is turned off. Domains added to it receive the service API, which covers every domain at once when sharing is turned on. Domains kept on the list get the copy they already hold updated. Returns the publication path to @@ -550,7 +550,7 @@ class PublishServiceOperations(Resource): kept_doms = [dom for dom in new_doms if dom in old_doms] added_doms = [dom for dom in new_doms if dom not in old_doms] - # Peers hold the copy under the name it carried when it was shared with them, so + # Peers hold the sharedAPI under the name it carried when it was shared with them, so # a modification renaming the service API must still be matched against the old one shared_api_name = old_service_api.get("api_name") @@ -606,8 +606,9 @@ class PublishServiceOperations(Resource): detail="Service API not existing", cause="Service API id not found") - # The copies held by interconnected CCFs go first, so that a peer that cannot - # be reached leaves the service api published here instead of orphaned there + # The shared APIs held by interconnected CCFs are removed first, so that when a peer cannot + # be reached, the service api remains published both on source and target CCF + # "_" contains the CCF ids where the API was successfully deleted _, interconnection_error = self.unpublish_from_interconnected_ccfs( serviceapidescription_dict.get("api_name"), shared_capif_prov_doms(serviceapidescription_dict.get("shareable_info"))) @@ -714,7 +715,7 @@ class PublishServiceOperations(Resource): invalid_params=[{"param": "apiStatus", "reason": "defined but apiStatusMoniroting feature not active"}] ) - # The publication path records where the copies live, so this CCF owns it and + # The publication path records where the CCFs holding the API live, so this CCF owns it and # the one held in the request is discarded service_api_description["pub_api_path"] = serviceapidescription_old.get("pub_api_path") -- GitLab