From ddb4aacfaa672cf7cc1441b31e642bd7594bf475 Mon Sep 17 00:00:00 2001 From: danielporterda Date: Wed, 1 Jul 2026 16:04:40 -0400 Subject: [PATCH] Add OpenAPI endpoint history notes Signed-off-by: danielporterda --- .../openapi/json-ledger-api/openapi.yaml | 325 ++++++++++++++ scripts/generate_json_api_reference.py | 37 +- scripts/openapi_history.py | 425 ++++++++++++++++++ tests/test_json_api_openapi.py | 71 +++ tests/test_openapi_history.py | 197 ++++++++ 5 files changed, 1048 insertions(+), 7 deletions(-) create mode 100644 scripts/openapi_history.py create mode 100644 tests/test_openapi_history.py diff --git a/docs-main/openapi/json-ledger-api/openapi.yaml b/docs-main/openapi/json-ledger-api/openapi.yaml index 71ecb92a1..91db225b3 100644 --- a/docs-main/openapi/json-ledger-api/openapi.yaml +++ b/docs-main/openapi/json-ledger-api/openapi.yaml @@ -10,6 +10,11 @@ paths: /v2/commands/submit-and-wait: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; request body schema changed; response `400` description updated. + summary: "POST /v2/commands/submit-and-wait" description: |- Submits a single composite command and waits for its result. @@ -45,6 +50,11 @@ - apiKeyAuth: [] /v2/commands/submit-and-wait-for-transaction: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/commands/submit-and-wait-for-transaction" description: |- Submits a single composite command, waits for its result, and returns the transaction. @@ -80,6 +90,11 @@ - apiKeyAuth: [] /v2/commands/submit-and-wait-for-reassignment: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/commands/submit-and-wait-for-reassignment" description: |- Submits a single composite reassignment command, waits for its result, and returns the reassignment. @@ -115,6 +130,11 @@ - apiKeyAuth: [] /v2/commands/submit-and-wait-for-transaction-tree: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: request body schema changed; response `400` description updated. Currently deprecated. + summary: "POST /v2/commands/submit-and-wait-for-transaction-tree" description: Submit a batch of commands and wait for the transaction trees response. Provided for backwards compatibility, it will be removed in the Canton version @@ -151,6 +171,11 @@ - apiKeyAuth: [] /v2/commands/async/submit: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; request body schema changed; response `400` description updated. + summary: "POST /v2/commands/async/submit" description: Submit a single composite command. operationId: postV2CommandsAsyncSubmit @@ -184,6 +209,11 @@ - apiKeyAuth: [] /v2/commands/async/submit-reassignment: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/commands/async/submit-reassignment" description: Submit a single reassignment. operationId: postV2CommandsAsyncSubmit-reassignment @@ -217,6 +247,11 @@ - apiKeyAuth: [] /v2/commands/completions: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/commands/completions" description: |- Query completions list (blocking call) @@ -278,6 +313,11 @@ - apiKeyAuth: [] /v2/events/events-by-contract-id: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/events/events-by-contract-id" description: |- Get the create and the consuming exercise event for the contract with the provided ID. @@ -315,6 +355,11 @@ - apiKeyAuth: [] /v2/version: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/version" description: Read the Ledger API version operationId: getV2Version @@ -342,6 +387,11 @@ - apiKeyAuth: [] /v2/dars/validate: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/dars/validate" description: |- Validates the DAR and checks the upgrade compatibility of the DAR's packages @@ -385,6 +435,11 @@ - apiKeyAuth: [] /v2/dars: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: response `400` description updated. + summary: "POST /v2/dars" description: Upload a DAR to the participant node operationId: postV2Dars @@ -431,6 +486,11 @@ - apiKeyAuth: [] /v2/packages: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/packages" description: Returns the identifiers of all supported packages. operationId: getV2Packages @@ -457,6 +517,11 @@ - httpAuth: [] - apiKeyAuth: [] post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/packages" description: |- Behaves the same as /dars. This endpoint will be deprecated and removed in a future release. @@ -509,6 +574,11 @@ - apiKeyAuth: [] /v2/packages/{package-id}: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/packages/:package-id" description: Returns the contents of a single package. operationId: getV2PackagesPackage-id @@ -548,6 +618,11 @@ - apiKeyAuth: [] /v2/packages/{package-id}/status: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/packages/:package-id/status" description: Returns the status of a single package. operationId: getV2PackagesPackage-idStatus @@ -581,6 +656,11 @@ - apiKeyAuth: [] /v2/package-vetting: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: response `400` description updated. Currently deprecated. + summary: "GET /v2/package-vetting" description: |- Lists which participant node vetted what packages on which synchronizer. @@ -616,6 +696,11 @@ - httpAuth: [] - apiKeyAuth: [] post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: response `400` description updated. Currently deprecated. + summary: "POST /v2/package-vetting" description: |- Update the vetted packages of this participant @@ -652,6 +737,11 @@ - apiKeyAuth: [] /v2/package-vetting/list: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/package-vetting/list" description: |- Lists which participant node vetted what packages on which synchronizer. @@ -687,6 +777,11 @@ - apiKeyAuth: [] /v2/package-vetting/update: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/package-vetting/update" description: Update the vetted packages of this participant operationId: postV2Package-vettingUpdate @@ -720,6 +815,11 @@ - apiKeyAuth: [] /v2/parties: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/parties" description: |- List the parties known by the participant. @@ -777,6 +877,11 @@ - httpAuth: [] - apiKeyAuth: [] post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; request body schema changed; response `400` description updated. + summary: "POST /v2/parties" description: |- Allocates a new party on a ledger and adds it to the set managed by the participant. @@ -827,6 +932,11 @@ - apiKeyAuth: [] /v2/parties/external/allocate: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; request body schema changed; response `400` description updated. + summary: "POST /v2/parties/external/allocate" description: |- The external party must be hosted (at least) on this node with either confirmation or observation permissions @@ -869,6 +979,11 @@ - apiKeyAuth: [] /v2/parties/participant-id: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/parties/participant-id" description: |- Return the identifier of the participant. @@ -900,6 +1015,11 @@ - apiKeyAuth: [] /v2/parties/{party}: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `200` schema changed; response `400` description updated. + summary: "GET /v2/parties/:party" description: |- Get the party details of the given parties. Only known parties will be @@ -947,6 +1067,11 @@ - httpAuth: [] - apiKeyAuth: [] patch: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "PATCH /v2/parties/:party" description: |- Update selected modifiable participant-local attributes of a party details resource. @@ -988,6 +1113,11 @@ - apiKeyAuth: [] /v2/parties/external/generate-topology: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/parties/external/generate-topology" description: |- You may use this endpoint to generate the common external topology transactions @@ -1027,6 +1157,11 @@ - apiKeyAuth: [] /v2/state/active-contracts: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; request body schema changed; response `400` description updated. + summary: "POST /v2/state/active-contracts" description: |- Query active contracts list (blocking call). @@ -1097,6 +1232,11 @@ - apiKeyAuth: [] /v2/state/active-contracts-page: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.5. + summary: "GET /v2/state/active-contracts-page" description: |- Returns a page of the snapshot of the active contracts and incomplete (un)assignments at a ledger offset. @@ -1135,6 +1275,11 @@ - apiKeyAuth: [] /v2/state/connected-synchronizers: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/state/connected-synchronizers" description: Get the list of connected synchronizers at the time of the query. operationId: getV2StateConnected-synchronizers @@ -1180,6 +1325,11 @@ - apiKeyAuth: [] /v2/state/ledger-end: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/state/ledger-end" description: |- Get the current ledger end. @@ -1209,6 +1359,11 @@ - apiKeyAuth: [] /v2/state/latest-pruned-offsets: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/state/latest-pruned-offsets" description: Get the latest successfully pruned ledger offsets operationId: getV2StateLatest-pruned-offsets @@ -1236,6 +1391,11 @@ - apiKeyAuth: [] /v2/updates: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; request body schema changed; response `400` description updated. + summary: "POST /v2/updates" description: |- Read the ledger's filtered update stream for the specified contents and filters. @@ -1300,6 +1460,11 @@ - apiKeyAuth: [] /v2/updates/flats: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: request body schema changed; response `400` description updated. Currently deprecated. + summary: "POST /v2/updates/flats" description: |- Query flat transactions update list (blocking call). Provided for backwards compatibility, it will be removed in the Canton version 3.5.0, use v2/updates instead. @@ -1360,6 +1525,11 @@ - apiKeyAuth: [] /v2/updates/trees: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: request body schema changed; response `400` description updated. Currently deprecated. + summary: "POST /v2/updates/trees" description: |- Query update transactions tree list (blocking call). Provided for backwards compatibility, it will be removed in the Canton version 3.5.0, use v2/updates instead. @@ -1420,6 +1590,11 @@ - apiKeyAuth: [] /v2/updates/transaction-tree-by-offset/{offset}: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: response `400` description updated. Currently deprecated. + summary: "GET /v2/updates/transaction-tree-by-offset/:offset" description: Get transaction tree by offset. Provided for backwards compatibility, it will be removed in the Canton version 3.5.0, use v2/updates/update-by-offset @@ -1465,6 +1640,11 @@ - apiKeyAuth: [] /v2/updates/transaction-by-offset: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: response `400` description updated. Currently deprecated. + summary: "POST /v2/updates/transaction-by-offset" description: Get transaction by offset. Provided for backwards compatibility, it will be removed in the Canton version 3.5.0, use v2/updates/update-by-offset @@ -1501,6 +1681,11 @@ - apiKeyAuth: [] /v2/updates/update-by-offset: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/updates/update-by-offset" description: |- Lookup an update by its offset. @@ -1536,6 +1721,11 @@ - apiKeyAuth: [] /v2/updates/transaction-by-id: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: response `400` description updated. Currently deprecated. + summary: "POST /v2/updates/transaction-by-id" description: Get transaction by id. Provided for backwards compatibility, it will be removed in the Canton version 3.5.0, use v2/updates/update-by-id instead. @@ -1571,6 +1761,11 @@ - apiKeyAuth: [] /v2/updates/update-by-id: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/updates/update-by-id" description: |- Lookup an update by its ID. @@ -1606,6 +1801,11 @@ - apiKeyAuth: [] /v2/updates/transaction-tree-by-id/{update-id}: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: response `400` description updated. Currently deprecated. + summary: "GET /v2/updates/transaction-tree-by-id/:update-id" description: Get transaction tree by id. Provided for backwards compatibility, it will be removed in the Canton version 3.5.0, use v2/updates/update-by-id @@ -1649,6 +1849,11 @@ - apiKeyAuth: [] /v2/updates/get-updates-page: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.5. + summary: "POST /v2/updates/get-updates-page" description: |- Read a page of ledger's filtered updates. It returns the event types in accordance with @@ -1688,6 +1893,11 @@ - apiKeyAuth: [] /v2/users: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/users" description: List all existing users. operationId: getV2Users @@ -1731,6 +1941,11 @@ - httpAuth: [] - apiKeyAuth: [] post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/users" description: Create a new user. operationId: postV2Users @@ -1764,6 +1979,11 @@ - apiKeyAuth: [] /v2/users/{user-id}: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/users/:user-id" description: Get the user data of a specific user or the authenticated user. operationId: getV2UsersUser-id @@ -1801,6 +2021,11 @@ - httpAuth: [] - apiKeyAuth: [] delete: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "DELETE /v2/users/:user-id" description: Delete an existing user and all its rights. operationId: deleteV2UsersUser-id @@ -1833,6 +2058,11 @@ - httpAuth: [] - apiKeyAuth: [] patch: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "PATCH /v2/users/:user-id" description: Update selected modifiable attribute of a user resource described by the ``User`` message. @@ -1873,6 +2103,11 @@ - apiKeyAuth: [] /v2/authenticated-user: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/authenticated-user" description: Get the user data of the current authenticated user. operationId: getV2Authenticated-user @@ -1906,6 +2141,11 @@ - apiKeyAuth: [] /v2/users/{user-id}/rights: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/users/:user-id/rights" description: List the set of all rights granted to a user. operationId: getV2UsersUser-idRights @@ -1938,6 +2178,11 @@ - httpAuth: [] - apiKeyAuth: [] post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/users/:user-id/rights" description: |- Grant rights to a user. @@ -1978,6 +2223,11 @@ - httpAuth: [] - apiKeyAuth: [] patch: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "PATCH /v2/users/:user-id/rights" description: |- Revoke rights from a user. @@ -2019,6 +2269,11 @@ - apiKeyAuth: [] /v2/users/{user-id}/identity-provider-id: patch: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "PATCH /v2/users/:user-id/identity-provider-id" description: Update the assignment of a user from one IDP to another. operationId: patchV2UsersUser-idIdentity-provider-id @@ -2058,6 +2313,11 @@ - apiKeyAuth: [] /v2/idps: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/idps" description: List all existing identity provider configurations. operationId: getV2Idps @@ -2084,6 +2344,11 @@ - httpAuth: [] - apiKeyAuth: [] post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/idps" description: |- Create a new identity provider configuration. @@ -2119,6 +2384,11 @@ - apiKeyAuth: [] /v2/idps/{idp-id}: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/idps/:idp-id" description: Get the identity provider configuration data by id. operationId: getV2IdpsIdp-id @@ -2151,6 +2421,11 @@ - httpAuth: [] - apiKeyAuth: [] delete: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "DELETE /v2/idps/:idp-id" description: Delete an existing identity provider configuration. operationId: deleteV2IdpsIdp-id @@ -2183,6 +2458,11 @@ - httpAuth: [] - apiKeyAuth: [] patch: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "PATCH /v2/idps/:idp-id" description: |- Update selected modifiable attribute of an identity provider config resource described @@ -2224,6 +2504,11 @@ - apiKeyAuth: [] /v2/interactive-submission/prepare: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; request body schema changed; response `200` schema changed; response `400` description updated. + summary: "POST /v2/interactive-submission/prepare" description: Requires `readAs` scope for the submitting party when LAPI User authorization is enabled @@ -2258,6 +2543,11 @@ - apiKeyAuth: [] /v2/interactive-submission/execute: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; request body schema changed; response `400` description updated. + summary: "POST /v2/interactive-submission/execute" description: |- Execute a prepared submission _asynchronously_ on the ledger. @@ -2294,6 +2584,11 @@ - apiKeyAuth: [] /v2/interactive-submission/executeAndWait: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; request body schema changed; response `400` description updated. + summary: "POST /v2/interactive-submission/executeAndWait" description: Similar to ExecuteSubmission but _synchronously_ wait for the completion of the transaction @@ -2328,6 +2623,11 @@ - apiKeyAuth: [] /v2/interactive-submission/executeAndWaitForTransaction: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; request body schema changed; response `400` description updated. + summary: "POST /v2/interactive-submission/executeAndWaitForTransaction" description: Similar to ExecuteSubmissionAndWait but additionally returns the transaction @@ -2362,6 +2662,11 @@ - apiKeyAuth: [] /v2/interactive-submission/preferred-package-version: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "GET /v2/interactive-submission/preferred-package-version" description: |- A preferred package is the highest-versioned package for a provided package-name @@ -2427,6 +2732,11 @@ - apiKeyAuth: [] /v2/interactive-submission/preferred-packages: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; request body schema changed; response `400` description updated. + summary: "POST /v2/interactive-submission/preferred-packages" description: |- Compute the preferred packages for the vetting requirements in the request. @@ -2476,6 +2786,11 @@ - apiKeyAuth: [] /livez: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.5. + summary: "GET /livez" description: Checks if the service is alive operationId: getLivez @@ -2499,6 +2814,11 @@ - apiKeyAuth: [] /readyz: get: + x-mint: + content: |- + + **Endpoint history**: Added in 3.5. + summary: "GET /readyz" description: Checks if the service is ready to serve requests operationId: getReadyz @@ -2526,6 +2846,11 @@ - apiKeyAuth: [] /v2/contracts/contract-by-id: post: + x-mint: + content: |- + + **Endpoint history**: Added in 3.4. Modified in 3.5: description updated; response `400` description updated. + summary: "POST /v2/contracts/contract-by-id" description: |- Looking up contract data by contract ID. diff --git a/scripts/generate_json_api_reference.py b/scripts/generate_json_api_reference.py index 1b23e442b..56502fed9 100755 --- a/scripts/generate_json_api_reference.py +++ b/scripts/generate_json_api_reference.py @@ -19,6 +19,7 @@ read_bundle_spec_text, selected_versions, ) +import openapi_history import reference_nav from x2mdx.output import Page, RawMarkdown from x2mdx.reference_pages import ( @@ -269,6 +270,23 @@ def normalize_mintlify_operation_summaries(openapi_path: Path) -> None: openapi_path.write_text(normalized, encoding="utf-8") +def enrich_mintlify_operation_history( + *, + openapi_path: Path, + specs_by_version: dict[str, dict[str, Any]], + versions: list[str], +) -> None: + histories = openapi_history.build_operation_histories(specs_by_version, versions) + original = openapi_path.read_text(encoding="utf-8") + enriched = openapi_history.enrich_openapi_text_with_history( + original, + histories, + first_version=versions[0], + ) + if enriched != original: + openapi_path.write_text(enriched, encoding="utf-8") + + def mintlify_openapi_page_refs(openapi_path: Path) -> list[str]: spec = yaml.safe_load(openapi_path.read_text(encoding="utf-8")) if not isinstance(spec, dict): @@ -497,6 +515,18 @@ def main() -> int: force_refresh=args.force_refresh, ) normalize_mintlify_operation_summaries(output_spec) + specs_by_version = versioned_openapi_specs( + source_config=source_config, + cache_dir=cache_dir, + versions=versions, + spec_filename="openapi.yaml", + force_refresh=args.force_refresh, + ) + enrich_mintlify_operation_history( + openapi_path=output_spec, + specs_by_version=specs_by_version, + versions=[entry["version"] for entry in versions], + ) print(f"Published Mintlify OpenAPI source: {output_spec}") docs_json_path = Path(args.docs_json).resolve() @@ -514,13 +544,6 @@ def main() -> int: details_page_ref=args.details_page_ref, openapi_page_refs=mintlify_openapi_page_refs(output_spec), ) - specs_by_version = versioned_openapi_specs( - source_config=source_config, - cache_dir=cache_dir, - versions=versions, - spec_filename="openapi.yaml", - force_refresh=args.force_refresh, - ) write_openapi_details_page( docs_json_path=docs_json_path, details_page_ref=args.details_page_ref, diff --git a/scripts/openapi_history.py b/scripts/openapi_history.py new file mode 100644 index 000000000..37687b18b --- /dev/null +++ b/scripts/openapi_history.py @@ -0,0 +1,425 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import re +from dataclasses import dataclass +from typing import Any + + +HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"} +HISTORY_MARKER = "**Endpoint history**:" + + +@dataclass(frozen=True) +class OperationChange: + version: str + changes: list[str] + + +@dataclass(frozen=True) +class OperationHistory: + method: str + path: str + added_version: str + changed_versions: list[OperationChange] + removed_version: str | None + deprecated: bool + + +def operation_key(method: str, path: str) -> str: + return f"{method.upper()} {path}" + + +def operation_items(spec: dict[str, Any]) -> dict[str, dict[str, Any]]: + paths = spec.get("paths") + if not isinstance(paths, dict): + return {} + + operations: dict[str, dict[str, Any]] = {} + for path, path_item in paths.items(): + if not isinstance(path, str) or not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + method_name = str(method).lower() + if method_name not in HTTP_METHODS or not isinstance(operation, dict): + continue + operations[operation_key(method_name, path)] = operation + return operations + + +def resolved_local_ref(spec: dict[str, Any], ref: str) -> Any: + if not ref.startswith("#/"): + return None + current: Any = spec + for raw_part in ref[2:].split("/"): + part = raw_part.replace("~1", "/").replace("~0", "~") + if not isinstance(current, dict) or part not in current: + return None + current = current[part] + return current + + +def collect_local_refs(value: Any, refs: set[str]) -> None: + if isinstance(value, dict): + ref = value.get("$ref") + if isinstance(ref, str) and ref.startswith("#/"): + refs.add(ref) + for item in value.values(): + collect_local_refs(item, refs) + elif isinstance(value, list): + for item in value: + collect_local_refs(item, refs) + + +def referenced_values(spec: dict[str, Any], operation: dict[str, Any]) -> dict[str, Any]: + seen: set[str] = set() + pending: set[str] = set() + collect_local_refs(operation, pending) + values: dict[str, Any] = {} + + while pending: + ref = pending.pop() + if ref in seen: + continue + seen.add(ref) + resolved = resolved_local_ref(spec, ref) + if resolved is None: + continue + values[ref] = strip_generated_history(copy.deepcopy(resolved)) + collect_local_refs(resolved, pending) + return values + + +def strip_generated_history(value: Any) -> Any: + if isinstance(value, dict): + cleaned: dict[str, Any] = {} + for key, item in value.items(): + if key == "x-mint" and isinstance(item, dict): + mint = { + mint_key: strip_generated_history(mint_value) + for mint_key, mint_value in item.items() + if not (mint_key == "content" and isinstance(mint_value, str) and HISTORY_MARKER in mint_value) + } + if mint: + cleaned[key] = mint + continue + cleaned[key] = strip_generated_history(item) + return cleaned + if isinstance(value, list): + return [strip_generated_history(item) for item in value] + return value + + +def fingerprint_operation(spec: dict[str, Any], operation: dict[str, Any]) -> str: + payload = { + "operation": strip_generated_history(copy.deepcopy(operation)), + "referenced": referenced_values(spec, operation), + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def schema_ref_or_type(value: Any) -> str: + if isinstance(value, dict): + ref = value.get("$ref") + if isinstance(ref, str): + return ref.rsplit("/", 1)[-1] + schema_type = value.get("type") + if isinstance(schema_type, str): + if schema_type == "array": + return f"array[{schema_ref_or_type(value.get('items'))}]" + return schema_type + for keyword in ("oneOf", "anyOf", "allOf"): + if isinstance(value.get(keyword), list): + return keyword + return "-" + + +def schema_signature(spec: dict[str, Any] | None, value: Any) -> str: + label = schema_ref_or_type(value) + resolved = value + if spec is not None and isinstance(value, dict) and isinstance(value.get("$ref"), str): + resolved = resolved_local_ref(spec, value["$ref"]) + if resolved is None: + return label + encoded = json.dumps(strip_generated_history(copy.deepcopy(resolved)), sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return f"{label}:{hashlib.sha256(encoded.encode('utf-8')).hexdigest()}" + + +def response_schemas(operation: dict[str, Any], spec: dict[str, Any] | None = None) -> dict[str, dict[str, str]]: + responses = operation.get("responses") + if not isinstance(responses, dict): + return {} + + output: dict[str, dict[str, str]] = {} + for code, response in responses.items(): + if not isinstance(response, dict): + continue + content = response.get("content") + if not isinstance(content, dict): + output[str(code)] = {} + continue + output[str(code)] = { + str(content_type): schema_signature(spec, media_type.get("schema") if isinstance(media_type, dict) else None) + for content_type, media_type in content.items() + } + return output + + +def request_body_schemas(operation: dict[str, Any], spec: dict[str, Any] | None = None) -> dict[str, str]: + request_body = operation.get("requestBody") + if not isinstance(request_body, dict): + return {} + content = request_body.get("content") + if not isinstance(content, dict): + return {} + return { + str(content_type): schema_signature(spec, media_type.get("schema") if isinstance(media_type, dict) else None) + for content_type, media_type in content.items() + } + + +def parameter_map(operation: dict[str, Any]) -> dict[tuple[str, str], str]: + parameters = operation.get("parameters") + if not isinstance(parameters, list): + return {} + output: dict[tuple[str, str], str] = {} + for parameter in parameters: + if not isinstance(parameter, dict): + continue + name = parameter.get("name") + location = parameter.get("in") + if not isinstance(name, str) or not isinstance(location, str): + continue + required = "required" if bool(parameter.get("required")) else "optional" + output[(location, name)] = f"{required}:{schema_ref_or_type(parameter.get('schema'))}" + return output + + +def summarize_operation_changes( + previous: dict[str, Any], + current: dict[str, Any], + *, + previous_spec: dict[str, Any] | None = None, + current_spec: dict[str, Any] | None = None, +) -> list[str]: + changes: list[str] = [] + if (previous.get("operationId") or "") != (current.get("operationId") or ""): + changes.append("operation id changed") + if (previous.get("summary") or "") != (current.get("summary") or ""): + changes.append("summary updated") + if (previous.get("description") or "") != (current.get("description") or ""): + changes.append("description updated") + if bool(previous.get("deprecated")) != bool(current.get("deprecated")): + changes.append("deprecation flag changed") + if (previous.get("x-state") or "") != (current.get("x-state") or ""): + changes.append("lifecycle state changed") + if (previous.get("x-replaces") or "") != (current.get("x-replaces") or ""): + changes.append("replacement target changed") + + previous_parameters = parameter_map(previous) + current_parameters = parameter_map(current) + for key in sorted(current_parameters.keys() - previous_parameters.keys()): + changes.append(f"{key[0]} parameter `{key[1]}` added") + for key in sorted(previous_parameters.keys() - current_parameters.keys()): + changes.append(f"{key[0]} parameter `{key[1]}` removed") + for key in sorted(previous_parameters.keys() & current_parameters.keys()): + if previous_parameters[key] != current_parameters[key]: + changes.append(f"{key[0]} parameter `{key[1]}` changed") + + previous_request = request_body_schemas(previous, previous_spec) + current_request = request_body_schemas(current, current_spec) + if not previous_request and current_request: + changes.append("request body added") + elif previous_request and not current_request: + changes.append("request body removed") + elif previous_request != current_request: + changes.append("request body schema changed") + + previous_responses = response_schemas(previous, previous_spec) + current_responses = response_schemas(current, current_spec) + for code in sorted(current_responses.keys() - previous_responses.keys()): + changes.append(f"response `{code}` added") + for code in sorted(previous_responses.keys() - current_responses.keys()): + changes.append(f"response `{code}` removed") + for code in sorted(previous_responses.keys() & current_responses.keys()): + previous_response = previous.get("responses", {}).get(code, {}) if isinstance(previous.get("responses"), dict) else {} + current_response = current.get("responses", {}).get(code, {}) if isinstance(current.get("responses"), dict) else {} + if isinstance(previous_response, dict) and isinstance(current_response, dict): + if (previous_response.get("description") or "") != (current_response.get("description") or ""): + changes.append(f"response `{code}` description updated") + if previous_responses[code] != current_responses[code]: + changes.append(f"response `{code}` schema changed") + + return changes or ["operation schema changed"] + + +def build_operation_histories(specs_by_version: dict[str, dict[str, Any]], versions: list[str]) -> dict[str, OperationHistory]: + operations_by_version = {version: operation_items(specs_by_version[version]) for version in versions} + all_keys = sorted({key for operations in operations_by_version.values() for key in operations}) + histories: dict[str, OperationHistory] = {} + + for key in all_keys: + present_versions = [version for version in versions if key in operations_by_version[version]] + if not present_versions: + continue + + changed_versions: list[OperationChange] = [] + previous_version: str | None = None + previous_hash: str | None = None + for version in present_versions: + operation = operations_by_version[version][key] + current_hash = fingerprint_operation(specs_by_version[version], operation) + if previous_hash is not None and current_hash != previous_hash and previous_version is not None: + changed_versions.append( + OperationChange( + version=version, + changes=summarize_operation_changes( + operations_by_version[previous_version][key], + operation, + previous_spec=specs_by_version[previous_version], + current_spec=specs_by_version[version], + ), + ) + ) + previous_hash = current_hash + previous_version = version + + method, path = key.split(" ", 1) + latest_operation = operations_by_version[present_versions[-1]][key] + latest_index = versions.index(present_versions[-1]) + removed_version = versions[latest_index + 1] if latest_index + 1 < len(versions) else None + histories[key] = OperationHistory( + method=method, + path=path, + added_version=present_versions[0], + changed_versions=changed_versions, + removed_version=removed_version, + deprecated=bool(latest_operation.get("deprecated")), + ) + + return histories + + +def compact_changes(changes: list[str], *, limit: int = 4) -> str: + if len(changes) <= limit: + return "; ".join(changes) + shown = "; ".join(changes[:limit]) + return f"{shown}; +{len(changes) - limit} more" + + +def history_note(history: OperationHistory, first_version: str) -> str | None: + parts: list[str] = [] + if history.added_version != first_version or history.changed_versions or history.deprecated: + parts.append(f"Added in {history.added_version}.") + for change in history.changed_versions: + parts.append(f"Modified in {change.version}: {compact_changes(change.changes)}.") + if history.deprecated: + parts.append("Currently deprecated.") + if history.removed_version is not None: + parts.append(f"Removed in {history.removed_version}.") + if not parts: + return None + return " ".join(parts) + + +def strip_generated_x_mint_content(text: str) -> str: + lines = text.splitlines() + output: list[str] = [] + index = 0 + while index < len(lines): + line = lines[index] + match = re.fullmatch(r"(?P\s*)x-mint:\s*", line) + if match is None: + output.append(line) + index += 1 + continue + + indent = match.group("indent") + block = [line] + index += 1 + while index < len(lines): + candidate = lines[index] + if candidate.strip() and not candidate.startswith(f"{indent} "): + break + block.append(candidate) + index += 1 + + if any(HISTORY_MARKER in block_line for block_line in block): + continue + output.extend(block) + return "\n".join(output).rstrip() + "\n" + + +def render_history_x_mint_block(indent: str, note: str) -> list[str]: + return [ + f"{indent}x-mint:", + f"{indent} content: |-", + f"{indent} ", + f"{indent} {HISTORY_MARKER} {note}", + f"{indent} ", + ] + + +def enrich_openapi_text_with_history( + text: str, + histories: dict[str, OperationHistory], + *, + first_version: str, +) -> str: + cleaned_text = strip_generated_x_mint_content(text) + lines = cleaned_text.splitlines() + output: list[str] = [] + in_paths = False + paths_indent = "" + current_path: str | None = None + current_path_indent: str | None = None + + for line in lines: + output.append(line) + + paths_match = re.fullmatch(r"(?P\s*)paths:\s*", line) + if paths_match: + in_paths = True + paths_indent = paths_match.group("indent") + current_path = None + current_path_indent = None + continue + + if not in_paths: + continue + + if line and not line.startswith(f"{paths_indent} "): + in_paths = False + current_path = None + current_path_indent = None + continue + + path_match = re.fullmatch(rf"(?P{re.escape(paths_indent)}\s{{2}})(?P/.*):\s*", line) + if path_match: + current_path = path_match.group("path") + current_path_indent = path_match.group("indent") + continue + + if current_path is None or current_path_indent is None: + continue + + method_match = re.fullmatch( + rf"(?P{re.escape(current_path_indent)}\s{{2}})(?P{'|'.join(sorted(HTTP_METHODS))}):\s*", + line, + ) + if method_match is None: + continue + + method = method_match.group("method").upper() + history = histories.get(operation_key(method, current_path)) + if history is None or history.removed_version is not None: + continue + note = history_note(history, first_version) + if note is None: + continue + output.extend(render_history_x_mint_block(f"{method_match.group('indent')} ", note)) + + return "\n".join(output).rstrip() + "\n" diff --git a/tests/test_json_api_openapi.py b/tests/test_json_api_openapi.py index bca535cb3..ee108d9a4 100644 --- a/tests/test_json_api_openapi.py +++ b/tests/test_json_api_openapi.py @@ -239,3 +239,74 @@ def test_build_openapi_details_page_uses_reference_overview_layout() -> None: assert '
' in rendered assert "Changed 3.5" in rendered assert "## Endpoint Reference (Latest)" not in rendered + + +def test_enrich_mintlify_operation_history_writes_visible_endpoint_notes(tmp_path: Path) -> None: + module = load_script_module("generate_json_api_reference.py") + openapi_path = tmp_path / "openapi.yaml" + openapi_path.write_text( + """openapi: 3.0.3 +paths: + /livez: + get: + summary: GET /livez + operationId: getLivez + responses: + '200': + description: OK + /v2/version: + get: + summary: GET /v2/version + description: Current version. + operationId: getV2Version + responses: + '200': + description: OK +components: {} +""", + encoding="utf-8", + ) + specs = { + "3.4": { + "paths": { + "/v2/version": { + "get": { + "summary": "GET /v2/version", + "description": "Old version.", + "operationId": "getV2Version", + "responses": {"200": {"description": "OK"}}, + } + } + } + }, + "3.5": { + "paths": { + "/livez": { + "get": { + "summary": "GET /livez", + "operationId": "getLivez", + "responses": {"200": {"description": "OK"}}, + } + }, + "/v2/version": { + "get": { + "summary": "GET /v2/version", + "description": "Current version.", + "operationId": "getV2Version", + "responses": {"200": {"description": "OK"}}, + } + }, + } + }, + } + + module.enrich_mintlify_operation_history( + openapi_path=openapi_path, + specs_by_version=specs, + versions=["3.4", "3.5"], + ) + + rendered = openapi_path.read_text(encoding="utf-8") + assert "**Endpoint history**: Added in 3.5." in rendered + assert "**Endpoint history**: Added in 3.4. Modified in 3.5: description updated." in rendered + assert rendered.count("x-mint:") == 2 diff --git a/tests/test_openapi_history.py b/tests/test_openapi_history.py new file mode 100644 index 000000000..f94bfe2ac --- /dev/null +++ b/tests/test_openapi_history.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def load_script_module(script_name: str) -> ModuleType: + script_path = REPO_ROOT / "scripts" / script_name + scripts_dir = str(script_path.parent) + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + spec = importlib.util.spec_from_file_location(script_path.stem, script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[script_path.stem] = module + spec.loader.exec_module(module) + return module + + +def versioned_specs() -> dict[str, dict]: + return { + "1.0": { + "openapi": "3.0.3", + "paths": { + "/unchanged": { + "get": { + "summary": "GET /unchanged", + "description": "Same endpoint.", + "operationId": "getUnchanged", + "responses": {"200": {"description": "OK"}}, + } + }, + "/modified": { + "post": { + "summary": "POST /modified", + "description": "Old description.", + "operationId": "postModified", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/Request"}} + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/Response"}} + }, + }, + "400": {"description": "Old error."}, + }, + } + }, + "/deprecated": { + "get": { + "summary": "GET /deprecated", + "operationId": "getDeprecated", + "responses": {"200": {"description": "OK"}}, + } + }, + "/removed": { + "delete": { + "summary": "DELETE /removed", + "operationId": "deleteRemoved", + "responses": {"204": {"description": "Removed."}}, + } + }, + }, + "components": { + "schemas": { + "Request": {"type": "object", "properties": {"id": {"type": "string"}}}, + "Response": {"type": "object", "properties": {"ok": {"type": "boolean"}}}, + } + }, + }, + "2.0": { + "openapi": "3.0.3", + "paths": { + "/unchanged": { + "get": { + "summary": "GET /unchanged", + "description": "Same endpoint.", + "operationId": "getUnchanged", + "responses": {"200": {"description": "OK"}}, + } + }, + "/modified": { + "post": { + "summary": "POST /modified", + "description": "New description.", + "operationId": "postModified", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/Request"}} + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/Response"}} + }, + }, + "400": {"description": "New error."}, + }, + } + }, + "/deprecated": { + "get": { + "summary": "GET /deprecated", + "operationId": "getDeprecated", + "deprecated": True, + "responses": {"200": {"description": "OK"}}, + } + }, + "/added": { + "get": { + "summary": "GET /added", + "operationId": "getAdded", + "responses": {"200": {"description": "OK"}}, + } + }, + }, + "components": { + "schemas": { + "Request": {"type": "object", "properties": {"id": {"type": "integer"}}}, + "Response": {"type": "object", "properties": {"ok": {"type": "string"}}}, + } + }, + }, + } + + +def test_operation_histories_track_added_modified_deprecated_and_removed() -> None: + module = load_script_module("openapi_history.py") + + histories = module.build_operation_histories(versioned_specs(), ["1.0", "2.0"]) + + assert histories["GET /added"].added_version == "2.0" + assert histories["DELETE /removed"].removed_version == "2.0" + assert histories["GET /deprecated"].deprecated is True + + modified = histories["POST /modified"].changed_versions[0] + assert modified.version == "2.0" + assert "description updated" in modified.changes + assert "request body schema changed" in modified.changes + assert "response `200` schema changed" in modified.changes + assert "response `400` description updated" in modified.changes + + +def test_enrich_openapi_text_uses_visible_x_mint_content_without_tombstones() -> None: + module = load_script_module("openapi_history.py") + histories = module.build_operation_histories(versioned_specs(), ["1.0", "2.0"]) + latest_text = """openapi: 3.0.3 +paths: + /unchanged: + get: + summary: GET /unchanged + operationId: getUnchanged + /modified: + post: + summary: POST /modified + operationId: postModified + /deprecated: + get: + summary: GET /deprecated + operationId: getDeprecated + deprecated: true + /added: + get: + summary: GET /added + operationId: getAdded +components: {} +""" + + enriched = module.enrich_openapi_text_with_history(latest_text, histories, first_version="1.0") + + assert "**Endpoint history**: Added in 2.0." in enriched + assert "Modified in 2.0: description updated; request body schema changed" in enriched + assert "Currently deprecated." in enriched + assert "DELETE /removed" not in enriched + assert enriched.count("x-mint:") == 3 + + assert module.enrich_openapi_text_with_history(enriched, histories, first_version="1.0") == enriched + + +def test_history_note_omits_unchanged_first_version_operations() -> None: + module = load_script_module("openapi_history.py") + histories = module.build_operation_histories(versioned_specs(), ["1.0", "2.0"]) + + assert module.history_note(histories["GET /unchanged"], "1.0") is None