diff --git a/docs/rpc/components/examples/fee-transaction-request.example.json b/docs/rpc/components/examples/fee-transaction-request.example.json deleted file mode 100644 index daa4dfd486a..00000000000 --- a/docs/rpc/components/examples/fee-transaction-request.example.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "estimated_len": 350, - "transaction_payload": "021af942874ce525e87f21bbe8c121b12fac831d02f4086765742d696e666f0b7570646174652d696e666f00000000" -} diff --git a/docs/rpc/components/examples/post-block-proposal-request.example.json b/docs/rpc/components/examples/post-block-proposal-request.example.json deleted file mode 100644 index d32f2b6e53a..00000000000 --- a/docs/rpc/components/examples/post-block-proposal-request.example.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "block": "00000000000000001f00000000000927c08fb5ae5bf80e39e4168f6a3fddb0407a069d21ee68465e6856393254d2a66194f44bb01070666d5effcfb2436e209a75878fe80a04b4258a8cd34ab97c38a8dde331a2a509dd7e4b90590726866172cc138c18e80567737667f55d3f9817ce4714c91d1adfd36101141829dc0b5ea0c4944668c0005ddb6f9e2718f60014f21932a42a36ffaf58e88e77b217b2af366c15dd59e6b136ca773729832dcfc5875ec0830d04012dd5a4fa77a196646ea2b356289116fd02558c034b62d63f8a65bdd20d7ffc3fec6c266cd974be776a9e92759b90f288dcc2525b6b6bd5622c5f02e0922440e9ad1095c19b4467fd94566caa9755669d8e0000000180800000000400f64081ae6209dce9245753a4f764d6f168aae1af00000000000000000000000000000064000041dbcc7391991c1a18371eb49b879240247a3ec7f281328f53976c1218ffd65421dbb101e59370e2c972b29f48dc674b2de5e1b65acbd41d5d2689124d42c16c01010000000000051a346048df62be3a52bb6236e11394e8600229e27b000000000000271000000000000000000000000000000000000000000000000000000000000000000000", - "chain_id": 2147483648 -} diff --git a/docs/rpc/components/examples/pox-info.example.json b/docs/rpc/components/examples/pox-info.example.json index 1659052c829..6b0861592dd 100644 --- a/docs/rpc/components/examples/pox-info.example.json +++ b/docs/rpc/components/examples/pox-info.example.json @@ -3,6 +3,7 @@ "pox_activation_threshold_ustx": 52329761604388, "first_burnchain_block_height": 666050, "current_burnchain_block_height": 812345, + "current_epoch": "Epoch33", "prepare_phase_block_length": 100, "reward_phase_block_length": 2000, "reward_slots": 4000, diff --git a/docs/rpc/components/examples/stackerdb-chunk-data-request.example.json b/docs/rpc/components/examples/stackerdb-chunk-data-request.example.json deleted file mode 100644 index b5de8f43b1c..00000000000 --- a/docs/rpc/components/examples/stackerdb-chunk-data-request.example.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "slot_id": 1, - "slot_version": 2, - "sig": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01", - "data": "deadbeefcafebabe" -} diff --git a/docs/rpc/components/parameters/contract-address.yaml b/docs/rpc/components/parameters/contract-address.yaml index abe6462fe8e..e4584026a4e 100644 --- a/docs/rpc/components/parameters/contract-address.yaml +++ b/docs/rpc/components/parameters/contract-address.yaml @@ -1,12 +1,9 @@ +# Deployer address for contract endpoints (standard principal format) name: contract_address in: path required: true description: | Standard Stacks address (e.g. `SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0`). - Must be 28-41 characters long using Stacks base58check format. + Must be 28-41 characters long using Stacks c32check format. schema: - type: string - pattern: "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}$" - minLength: 28 - maxLength: 41 - example: SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 + $ref: '../schemas/standard-principal.schema.yaml' diff --git a/docs/rpc/components/parameters/principal.yaml b/docs/rpc/components/parameters/principal.yaml index fa0e4d75928..66d6a0c049b 100644 --- a/docs/rpc/components/parameters/principal.yaml +++ b/docs/rpc/components/parameters/principal.yaml @@ -6,8 +6,4 @@ description: | (e.g. `SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info`). Contract names have a maximum length of 40 characters for new contracts. Legacy contracts may have names up to 128 characters. schema: - type: string - pattern: "^([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41})|([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})$" - minLength: 28 - maxLength: 170 - example: SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info + $ref: '../schemas/principal.schema.yaml' diff --git a/docs/rpc/components/parameters/standard-principal.yaml b/docs/rpc/components/parameters/standard-principal.yaml new file mode 100644 index 00000000000..fc708b83f4b --- /dev/null +++ b/docs/rpc/components/parameters/standard-principal.yaml @@ -0,0 +1,8 @@ +name: principal +in: path +required: true +description: | + Standard Stacks address (standard principal, not contract principal). + Must be 28-41 characters long using Stacks c32check format. +schema: + $ref: '../schemas/standard-principal.schema.yaml' diff --git a/docs/rpc/components/schemas/clarity-data.schema.yaml b/docs/rpc/components/schemas/clarity-data.schema.yaml index 53082adfb04..9022074fb56 100644 --- a/docs/rpc/components/schemas/clarity-data.schema.yaml +++ b/docs/rpc/components/schemas/clarity-data.schema.yaml @@ -9,5 +9,5 @@ properties: pattern: "^0x[0-9a-f]+$" proof: type: string - description: Hex-encoded 0x prefixed string of the Merkle proof for the data - pattern: "^0x[0-9a-f]+$" + description: Hex-encoded 0x prefixed string of the Merkle proof for the data. Empty string if proof not requested. + pattern: "^(0x[0-9a-f]+)?$" diff --git a/docs/rpc/components/schemas/clarity-name.schema.yaml b/docs/rpc/components/schemas/clarity-name.schema.yaml index e4dc469e40f..9a0d644376f 100644 --- a/docs/rpc/components/schemas/clarity-name.schema.yaml +++ b/docs/rpc/components/schemas/clarity-name.schema.yaml @@ -1,4 +1,9 @@ type: string -pattern: "^([a-zA-Z0-9_]|[-!?+<>=/*]){1,128}$" +description: | + A valid Clarity name. Must either: + - Start with a letter and contain only letters, numbers, and [-_!?+<>=/*] + - Be exactly one of the special characters: - + = * / + - Be a comparison operator: < > <= >= +pattern: "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$" minLength: 1 maxLength: 128 diff --git a/docs/rpc/components/schemas/pox-info.schema.yaml b/docs/rpc/components/schemas/pox-info.schema.yaml index ea9abc2cb85..70277db5551 100644 --- a/docs/rpc/components/schemas/pox-info.schema.yaml +++ b/docs/rpc/components/schemas/pox-info.schema.yaml @@ -125,7 +125,6 @@ properties: Any eligible stacks must be stacked before this block. blocks_until_prepare_phase: type: integer - minimum: 0 description: The number of burn blocks until the prepare phase for this cycle starts. If the prepare phase for this cycle already started, this value diff --git a/docs/rpc/components/schemas/principal.schema.yaml b/docs/rpc/components/schemas/principal.schema.yaml new file mode 100644 index 00000000000..a66900ac9a6 --- /dev/null +++ b/docs/rpc/components/schemas/principal.schema.yaml @@ -0,0 +1,11 @@ +# Standard principal or contract principal (address.contract-name) +type: string +pattern: "^S[PTMN][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}(\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})?$" +minLength: 28 +maxLength: 170 +examples: + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info + - ST000000000000000000002AMW42H + - SM2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQVX8X0G + - SN2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKP6D2ZK9 diff --git a/docs/rpc/components/schemas/read-only-function-args.schema.yaml b/docs/rpc/components/schemas/read-only-function-args.schema.yaml index d72cdea84b2..ee4ad260802 100644 --- a/docs/rpc/components/schemas/read-only-function-args.schema.yaml +++ b/docs/rpc/components/schemas/read-only-function-args.schema.yaml @@ -1,17 +1,22 @@ description: Describes representation of a Type-0 Stacks 2.0 transaction. https://github.com/stacksgov/sips/blob/main/sips/sip-005/sip-005-blocks-and-transactions.md#type-0-transferring-an-asset type: object +additionalProperties: false required: - sender - arguments properties: sender: - type: string - description: The simulated tx-sender + description: The simulated tx-sender. + allOf: + - $ref: './standard-principal.schema.yaml' sponsor: - type: string - description: The simulated sponsor address + description: The simulated sponsor address. + allOf: + - $ref: './principal.schema.yaml' arguments: type: array description: An array of hex serialized Clarity values items: type: string + pattern: "^(0x)?([0-9a-fA-F]{2})+$" + description: Hex-encoded Clarity value (optionally prefixed with 0x) diff --git a/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml b/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml index f31cafbb7e6..6647a1eb794 100644 --- a/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml +++ b/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml @@ -8,16 +8,18 @@ properties: slot_id: type: integer minimum: 0 - description: Slot identifier + maximum: 4294967295 + description: Slot identifier (u32 range) slot_version: type: integer minimum: 0 - description: Slot version (lamport clock) + maximum: 4294967295 + description: Slot version (lamport clock, u32 range) sig: type: string description: Hex-encoded signature from the stacker pattern: "^[0-9a-f]{130}$" data: type: string - description: Hex-encoded chunk data - pattern: "^[0-9a-f]*$" + description: Hex-encoded chunk data (must be even length) + pattern: "^([0-9a-f]{2})*$" diff --git a/docs/rpc/components/schemas/standard-principal.schema.yaml b/docs/rpc/components/schemas/standard-principal.schema.yaml new file mode 100644 index 00000000000..a8057553376 --- /dev/null +++ b/docs/rpc/components/schemas/standard-principal.schema.yaml @@ -0,0 +1,10 @@ +# Standard principal only, no contract suffix +type: string +pattern: "^S[PTMN][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}$" +minLength: 28 +maxLength: 41 +examples: + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 + - ST000000000000000000002AMW42H + - SM2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQVX8X0G + - SN2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKP6D2ZK9 diff --git a/docs/rpc/components/schemas/transaction-submission-error.schema.yaml b/docs/rpc/components/schemas/transaction-submission-error.schema.yaml index f78efc464c6..261bd3c54af 100644 --- a/docs/rpc/components/schemas/transaction-submission-error.schema.yaml +++ b/docs/rpc/components/schemas/transaction-submission-error.schema.yaml @@ -3,7 +3,6 @@ type: object required: - error - reason - - reason_data - txid properties: error: diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index d70875a00ec..d0c71b62283 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -74,6 +74,13 @@ components: schema: type: string example: "Internal Server Error" + MethodNotAllowed: + description: Method Not Allowed + content: + text/plain: + schema: + type: string + example: "Method Not Allowed. Allowed: GET" Timeout: description: Timeout content: @@ -182,11 +189,13 @@ paths: - Size limits operationId: broadcastTransaction requestBody: + required: true content: application/octet-stream: schema: type: string format: binary + minLength: 1 example: binary format of 00000000010400bed38c2aadffa348931bcb542880ff79d607afec000000000000000000000000000000c800012b0b1fff6cccd0974966dcd665835838f0985be508e1322e09fb3d751eca132c492bda720f9ef1768d14fdabed6127560ba52d5e3ac470dcb60b784e97dc88c9030200000000000516df0ba3e79792be7be5e50a370289accfc8c9e032000000000000303974657374206d656d6f00000000000000000000000000000000000000000000000000 application/json: schema: @@ -221,6 +230,10 @@ paths: $ref: "#/components/schemas/TransactionSubmissionError" example: $ref: "./components/examples/transaction-submission-error.example.json" + text/plain: + schema: + type: string + example: "Failed to decode transaction" "500": $ref: "#/components/responses/InternalServerError" @@ -293,6 +306,10 @@ paths: application/json: schema: type: string + minLength: 2 + pattern: "^(0x)?([0-9a-fA-F]{2})+$" + description: Hex-encoded Clarity value (e.g. "0x0100000000000000000000000000000001") + example: "0x0100000000000000000000000000000001" responses: "200": description: Success @@ -572,7 +589,8 @@ paths: schema: $ref: "#/components/schemas/FeeTransactionRequest" example: - $ref: "./components/examples/fee-transaction-request.example.json" + estimated_len: 350 + transaction_payload: "021af942874ce525e87f21bbe8c121b12fac831d02f4086765742d696e666f0b7570646174652d696e666f00000000" responses: "200": description: Estimated fees for the transaction @@ -588,6 +606,10 @@ paths: application/json: schema: $ref: "#/components/schemas/FeeTransactionError" + text/plain: + schema: + type: string + example: "Failed to decode: Failed to parse JSON body" "500": $ref: "#/components/responses/InternalServerError" @@ -651,6 +673,12 @@ paths: $ref: "#/components/schemas/PoxInfo" example: $ref: "./components/examples/pox-info.example.json" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" parameters: - $ref: ./components/parameters/tip.yaml @@ -673,11 +701,7 @@ paths: description: | Stacks address of the trait-defining contract. schema: - type: string - pattern: "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}$" - minLength: 28 - maxLength: 41 - example: "SP2Z1K16238380NBP4T38A4G10A90Q03JJ2C2003" + $ref: './components/schemas/standard-principal.schema.yaml' - name: trait_contract_name in: path required: true @@ -779,9 +803,15 @@ paths: - name: clarity_metadata_key in: path required: true - description: Metadata key + description: | + Metadata key. Must be either: + - "analysis" for contract analysis data + - "vm-metadata::N::TYPE" where N is the epoch (1-2 digits) and TYPE is one of: + contract, contract-size, contract-src, contract-data-size, or a valid Clarity name schema: type: string + pattern: "^(analysis)|(vm-metadata::\\d{1,2}::(contract|contract-size|contract-src|contract-data-size|[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*))$" + example: analysis - $ref: ./components/parameters/tip.yaml /v2/constant_val/{contract_address}/{contract_name}/{constant_name}: @@ -843,12 +873,16 @@ paths: properties: block: type: string - description: Hex-encoded block data + minLength: 400 + pattern: "^([0-9a-fA-F]{2})+$" + description: Hex-encoded block data (must be valid SIP-003 serialized block, typically 200+ bytes) chain_id: type: integer + minimum: 0 description: Chain ID for the block example: - $ref: "./components/examples/post-block-proposal-request.example.json" + block: "00000000000000001f00000000000927c08fb5ae5bf80e39e4168f6a3fddb0407a069d21ee68465e6856393254d2a66194f44bb01070666d5effcfb2436e209a75878fe80a04b4258a8cd34ab97c38a8dde331a2a509dd7e4b90590726866172cc138c18e80567737667f55d3f9817ce4714c91d1adfd36101141829dc0b5ea0c4944668c0005ddb6f9e2718f60014f21932a42a36ffaf58e88e77b217b2af366c15dd59e6b136ca773729832dcfc5875ec0830d04012dd5a4fa77a196646ea2b356289116fd02558c034b62d63f8a65bdd20d7ffc3fec6c266cd974be776a9e92759b90f288dcc2525b6b6bd5622c5f02e0922440e9ad1095c19b4467fd94566caa9755669d8e0000000180800000000400f64081ae6209dce9245753a4f764d6f168aae1af00000000000000000000000000000064000041dbcc7391991c1a18371eb49b879240247a3ec7f281328f53976c1218ffd65421dbb101e59370e2c972b29f48dc674b2de5e1b65acbd41d5d2689124d42c16c01010000000000051a346048df62be3a52bb6236e11394e8600229e27b000000000000271000000000000000000000000000000000000000000000000000000000000000000000" + chain_id: 2147483648 responses: "202": description: | @@ -939,6 +973,13 @@ paths: description: Detailed error message example: $ref: "./components/examples/get-stacker-set-400.example.json" + "404": + description: Reward cycle not found or does not exist + content: + text/plain: + schema: + type: string + example: "No such file or directory" /v3/blocks/{block_id}: get: @@ -956,10 +997,15 @@ paths: parameters: - name: block_id in: path - description: The block"s ID hash + description: The block's ID hash (64-character hex string) required: true schema: type: string + pattern: "^[0-9a-f]{64}$" + minLength: 64 + maxLength: 64 + examples: + - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" responses: "200": description: The raw SIP-003-encoded block will be returned. @@ -972,6 +1018,8 @@ paths: $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" + "405": + $ref: "#/components/responses/MethodNotAllowed" "500": $ref: "#/components/responses/InternalServerError" @@ -986,10 +1034,12 @@ paths: parameters: - name: block_height in: path - description: The block's height + description: The block's height (max 4294967295) required: true schema: type: integer + minimum: 0 + maximum: 4294967295 - $ref: ./components/parameters/tip.yaml responses: "200": @@ -1041,10 +1091,15 @@ paths: parameters: - name: block_id in: path - description: The tenure-start block ID of the tenure to query + description: The tenure-start block ID of the tenure to query (64-character hex string) required: true schema: type: string + pattern: "^[0-9a-f]{64}$" + minLength: 64 + maxLength: 64 + examples: + - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - name: stop in: query description: | @@ -1055,6 +1110,9 @@ paths: required: false schema: type: string + pattern: "^[0-9a-f]{64}$" + minLength: 64 + maxLength: 64 responses: "200": description: SIP-003-encoded Nakamoto blocks, concatenated together @@ -1063,6 +1121,14 @@ paths: schema: type: string format: binary + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "405": + $ref: "#/components/responses/MethodNotAllowed" + "500": + $ref: "#/components/responses/InternalServerError" /v3/tenures/blocks/{consensus_hash}: get: @@ -1149,6 +1215,8 @@ paths: required: true schema: type: integer + minimum: 0 + maximum: 4294967295 responses: "200": description: List of Stacks blocks in the tenure @@ -1234,7 +1302,7 @@ paths: description: Hex-encoded consensus hash (40 characters) schema: type: string - pattern: "^(0x)?[0-9a-f]{40}$" + pattern: "^[0-9a-f]{40}$" responses: "200": description: Sortition information for the consensus hash @@ -1268,7 +1336,7 @@ paths: description: Hex-encoded burn header hash (64 characters) schema: type: string - pattern: "^(0x)?[0-9a-f]{64}$" + pattern: "^[0-9a-f]{64}$" responses: "200": description: Sortition information for the burn header hash @@ -1299,10 +1367,11 @@ paths: - name: height in: path required: true - description: Burn block height (integer) + description: Burn block height (integer, max 4294967295) schema: type: integer minimum: 0 + maximum: 4294967295 responses: "200": description: Sortition information for the burn block height @@ -1353,6 +1422,12 @@ paths: $ref: "#/components/schemas/SignerBlocksSigned" example: blocks_signed: 7 + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /v3/transaction/{txid}: get: @@ -1381,6 +1456,8 @@ paths: $ref: "#/components/schemas/TransactionInfo" example: $ref: "./components/examples/get-transaction.example.json" + "400": + $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "500": @@ -1474,12 +1551,16 @@ paths: - name: pages_indexes in: query required: true - description: Comma-separated list of page indexes to query + description: | + Comma-separated list of page indexes to query. + - Maximum 8 pages per request + - Each index must be 0-4294967295 (u32 range) + - Values outside u32 range return 400 Bad Request schema: type: string example: "1,2,3" - pattern: "^[0-9]+(,[0-9]+){0,7}$" - description: max 8 pages per request + # Pattern allows up to 10 digits; API validates u32 range at runtime + pattern: "^[0-9]{1,10}(,[0-9]{1,10}){0,7}$" responses: "200": description: Attachment inventory bitfield @@ -1527,6 +1608,8 @@ paths: schema: type: string format: binary + "400": + $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "500": @@ -1626,6 +1709,7 @@ paths: schema: type: string format: binary + minLength: 1 responses: "200": description: Index-block hash of the accepted microblock @@ -1653,15 +1737,16 @@ paths: description: | Get the latest version of a chunk of data from a StackerDB instance. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml - name: slot_id in: path required: true - description: Slot ID + description: Slot ID (u32 range) schema: type: integer minimum: 0 + maximum: 4294967295 responses: "200": description: StackerDB chunk data @@ -1670,6 +1755,8 @@ paths: schema: type: string format: binary + "400": + $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "500": @@ -1685,22 +1772,24 @@ paths: description: | Get a specific version of a chunk of data from a StackerDB instance. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml - name: slot_id in: path required: true - description: Slot ID + description: Slot ID (u32 range) schema: type: integer minimum: 0 + maximum: 4294967295 - name: slot_version in: path required: true - description: Specific slot version + description: Specific slot version (u32 range) schema: type: integer minimum: 0 + maximum: 4294967295 responses: "200": description: StackerDB chunk data @@ -1726,7 +1815,7 @@ paths: description: | Get metadata about a StackerDB instance, including slot information. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml responses: "200": @@ -1764,7 +1853,7 @@ paths: detailed error information. Note that failed writes return HTTP 200 with accepted: false, not HTTP error codes. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml requestBody: required: true @@ -1773,7 +1862,10 @@ paths: schema: $ref: "#/components/schemas/StackerDbChunkData" example: - $ref: "./components/examples/stackerdb-chunk-data-request.example.json" + slot_id: 1 + slot_version: 2 + sig: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01" + data: "deadbeefcafebabe" responses: "200": description: Chunk submission result (both success and failure cases) @@ -1805,7 +1897,7 @@ paths: description: | Get a list of replicas for a StackerDB instance. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml responses: "200": @@ -1836,14 +1928,14 @@ paths: Fetch a data variable from a smart contract. Returns the raw hex-encoded value of the variable. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml - name: var_name in: path required: true - description: Variable name + description: Variable name (must be a valid Clarity name) schema: - type: string + $ref: "#/components/schemas/ClarityName" - $ref: ./components/parameters/proof.yaml - $ref: ./components/parameters/tip.yaml responses: @@ -2083,6 +2175,7 @@ paths: schema: type: string format: binary + minLength: 1 description: Binary-encoded Stacks block responses: "200": @@ -2125,15 +2218,10 @@ paths: schema: type: string format: binary + minLength: 1 description: | Binary SIP-003 encoding of `MemPoolSyncData` (`BloomFilter` or `TxTags` variants). - properties: - transactions: - type: array - items: - type: string - description: Transaction IDs responses: "200": description: Stream of missing transactions @@ -2185,6 +2273,7 @@ paths: schema: type: string format: binary + minLength: 1 description: Binary SIP-003 encoding of a `NakamotoBlock` responses: "200": diff --git a/stackslib/src/net/http/error.rs b/stackslib/src/net/http/error.rs index 31b91a76945..5b880e22646 100644 --- a/stackslib/src/net/http/error.rs +++ b/stackslib/src/net/http/error.rs @@ -128,6 +128,7 @@ pub fn http_error_from_code_and_text(code: u16, message: String) -> Box Box::new(HttpPaymentRequired::new(message)), 403 => Box::new(HttpForbidden::new(message)), 404 => Box::new(HttpNotFound::new(message)), + 405 => Box::new(HttpMethodNotAllowed::new(message)), 408 => Box::new(HttpRequestTimeout::new(message)), 500 => Box::new(HttpServerError::new(message)), 501 => Box::new(HttpNotImplemented::new(message)), @@ -293,6 +294,52 @@ impl HttpErrorResponse for HttpNotFound { } } +/// HTTP 405 +pub struct HttpMethodNotAllowed { + error_text: String, + allowed_methods: Vec, +} + +impl HttpMethodNotAllowed { + pub fn new(error_text: String) -> Self { + Self { + error_text, + allowed_methods: vec![], + } + } + + pub fn with_allowed_methods(allowed_methods: Vec) -> Self { + let error_text = format!( + "Method Not Allowed. Allowed: {}", + allowed_methods.join(", ") + ); + Self { + error_text, + allowed_methods, + } + } + + pub fn get_allowed_methods(&self) -> &[String] { + &self.allowed_methods + } +} + +impl HttpErrorResponse for HttpMethodNotAllowed { + fn code(&self) -> u16 { + 405 + } + fn payload(&self) -> HttpResponsePayload { + HttpResponsePayload::Text(self.error_text.clone()) + } + fn try_parse_response( + &self, + preamble: &HttpResponsePreamble, + body: &[u8], + ) -> Result { + try_parse_error_response(preamble.status_code, preamble.content_type, body) + } +} + /// HTTP 408 pub struct HttpRequestTimeout { error_text: String, diff --git a/stackslib/src/net/http/mod.rs b/stackslib/src/net/http/mod.rs index ce7c00dc066..39b50426af4 100644 --- a/stackslib/src/net/http/mod.rs +++ b/stackslib/src/net/http/mod.rs @@ -37,8 +37,8 @@ pub use crate::net::http::common::{ }; pub use crate::net::http::error::{ http_error_from_code_and_text, http_reason, HttpBadRequest, HttpError, HttpErrorResponse, - HttpForbidden, HttpNotFound, HttpNotImplemented, HttpPaymentRequired, HttpRequestTimeout, - HttpServerError, HttpServiceUnavailable, HttpUnauthorized, + HttpForbidden, HttpMethodNotAllowed, HttpNotFound, HttpNotImplemented, HttpPaymentRequired, + HttpRequestTimeout, HttpServerError, HttpServiceUnavailable, HttpUnauthorized, }; pub use crate::net::http::request::{ HttpRequest, HttpRequestContents, HttpRequestPayload, HttpRequestPreamble, @@ -64,6 +64,8 @@ pub enum Error { UnderflowError(String), /// Http error response Http(u16, String), + /// Http 405 error response with the list of allowed methods (for Allow header) + HttpMethodNotAllowed(Vec), /// Application error AppError(String), } @@ -78,6 +80,9 @@ impl fmt::Display for Error { Error::ReadError(io_error) => fmt::Display::fmt(&io_error, f), Error::UnderflowError(msg) => write!(f, "{}", msg), Error::Http(code, msg) => write!(f, "code={}, msg={}", code, msg), + Error::HttpMethodNotAllowed(methods) => { + write!(f, "code=405, allowed={}", methods.join(", ")) + } Error::AppError(msg) => write!(f, "{}", &msg), } } @@ -93,6 +98,7 @@ impl std::error::Error for Error { Error::ReadError(io_error) => Some(io_error), Error::UnderflowError(_) => None, Error::Http(..) => None, + Error::HttpMethodNotAllowed(_) => None, Error::AppError(_) => None, } } @@ -138,6 +144,9 @@ impl Error { &x ))), Error::Http(code, msg) => http_error_from_code_and_text(code, msg), + Error::HttpMethodNotAllowed(methods) => { + Box::new(HttpMethodNotAllowed::with_allowed_methods(methods)) + } Error::AppError(x) => Box::new(HttpServerError::new(format!( "Unhandled application error: {:?}", &x diff --git a/stackslib/src/net/http/request.rs b/stackslib/src/net/http/request.rs index 6b5b1a6e19b..965a3285413 100644 --- a/stackslib/src/net/http/request.rs +++ b/stackslib/src/net/http/request.rs @@ -694,6 +694,13 @@ pub trait HttpRequest: Send + HttpRequestClone { fn verb(&self) -> &'static str; /// What is the path regex that this request honors? fn path_regex(&self) -> Regex; + /// This regex matches the path structure without strict parameter validation, + /// allowing the API to return 405 Method Not Allowed when the path exists + /// but the HTTP method is not supported. + /// Default implementation returns the same as path_regex(). + fn path_regex_permissive(&self) -> Regex { + self.path_regex() + } /// Decode a request into the contents that this request handler cares about. fn try_parse_request( &mut self, diff --git a/stackslib/src/net/httpcore.rs b/stackslib/src/net/httpcore.rs index 5502cb64e2f..64dcedbe6e2 100644 --- a/stackslib/src/net/httpcore.rs +++ b/stackslib/src/net/httpcore.rs @@ -47,8 +47,8 @@ use crate::net::connection::{ConnectionOptions, NetworkConnection}; use crate::net::http::common::{parse_raw_bytes, HTTP_PREAMBLE_MAX_ENCODED_SIZE}; use crate::net::http::{ http_reason, parse_bytes, parse_json, Error as HttpError, HttpContentType, HttpErrorResponse, - HttpNotFound, HttpRequest, HttpRequestContents, HttpRequestPreamble, HttpResponse, - HttpResponseContents, HttpResponsePayload, HttpResponsePreamble, HttpServerError, + HttpMethodNotAllowed, HttpNotFound, HttpRequest, HttpRequestContents, HttpRequestPreamble, + HttpResponse, HttpResponseContents, HttpResponsePayload, HttpResponsePreamble, HttpServerError, }; use crate::net::p2p::PeerNetwork; use crate::net::server::HttpPeer; @@ -71,6 +71,19 @@ pub const HTTP_REQUEST_ID_RESERVED: u32 = 0; /// The interval at which to send heartbeat logs const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60); +/// Finds named path parameters like (?P...) in route patterns +static CAPTURE_GROUP_REGEX: std::sync::LazyLock = + std::sync::LazyLock::new(|| Regex::new(r"\(\?P<[^>]+>[^)]+\)").unwrap()); + +/// Loosens a strict route regex so any valid URL characters match the parameters. +/// This lets us tell apart 400 (bad params), 404 (no such route), and 405 (wrong method). +/// Complex patterns with nested parens fall back to strict matching (404 instead of 400). +fn make_permissive_regex(strict_regex: &Regex) -> Regex { + let pattern = strict_regex.as_str(); + let permissive = CAPTURE_GROUP_REGEX.replace_all(pattern, "[a-zA-Z0-9._~:@!$&'()*+,;=-]+"); + Regex::new(&permissive).unwrap_or_else(|_| strict_regex.clone()) +} + /// All representations of the `tip=` query parameter value #[derive(Debug, Clone, PartialEq)] pub enum TipRequest { @@ -656,6 +669,15 @@ impl StacksHttpResponse { pub fn new_error( preamble: &HttpRequestPreamble, error: &dyn HttpErrorResponse, + ) -> StacksHttpResponse { + Self::new_error_with_headers(preamble, error, vec![]) + } + + /// Make a new HTTP error response with additional headers, in reaction to a request + pub fn new_error_with_headers( + preamble: &HttpRequestPreamble, + error: &dyn HttpErrorResponse, + extra_headers: Vec<(String, String)>, ) -> StacksHttpResponse { let payload = error.payload(); let content_type = match &payload { @@ -665,14 +687,17 @@ impl StacksHttpResponse { HttpResponsePayload::JSON(..) => HttpContentType::JSON, }; let content_length = payload.try_content_length(); - let preamble = HttpResponsePreamble::from_http_request_preamble( + let mut resp_preamble = HttpResponsePreamble::from_http_request_preamble( preamble, error.code(), http_reason(error.code()), content_length, content_type, ); - StacksHttpResponse::new(preamble, payload) + for (key, value) in extra_headers { + resp_preamble.add_header(key, value); + } + StacksHttpResponse::new(resp_preamble, payload) } /// Make a new HTTP error response for text, apropos of nothing @@ -780,15 +805,25 @@ impl StacksMessageCodec for StacksHttpPreamble { // underflow? match (e_request, e) { (CodecError::ReadError(ref ioe1), CodecError::ReadError(ref ioe2)) => { - if ioe1.kind() == io::ErrorKind::UnexpectedEof && ioe2.kind() == io::ErrorKind::UnexpectedEof { + if ioe1.kind() == io::ErrorKind::UnexpectedEof + && ioe2.kind() == io::ErrorKind::UnexpectedEof + { // out of bytes - Err(CodecError::UnderflowError("Not enough bytes to form a HTTP request or response".to_string())) + Err(CodecError::UnderflowError( + "Not enough bytes to form a HTTP request or response" + .to_string(), + )) + } else { + Err(CodecError::DeserializeError(format!( + "Neither a HTTP request ({:?}) or HTTP response ({:?})", + ioe1, ioe2 + ))) } - else { - Err(CodecError::DeserializeError(format!("Neither a HTTP request ({:?}) or HTTP response ({:?})", ioe1, ioe2))) - } - }, - (e_req, e_res) => Err(CodecError::DeserializeError(format!("Failed to decode HTTP request or HTTP response (request error: {:?}; response error: {:?})", &e_req, &e_res))) + } + (e_req, e_res) => Err(CodecError::DeserializeError(format!( + "Failed to decode HTTP request or HTTP response (request error: {:?}; response error: {:?})", + &e_req, &e_res + ))), } } } @@ -965,8 +1000,9 @@ pub struct StacksHttp { /// parse a reply. If instead this state-machine is used by the server to parse a request and /// send a reply, it will be unused. request_handler_index: Option, - /// HTTP request handlers (verb, regex, request-handler, response-handler) - request_handlers: Vec<(String, Regex, Box)>, + /// HTTP request handlers (verb, regex, permissive_regex, request-handler) + /// The permissive_regex is used for 405 Method Not Allowed detection + request_handlers: Vec<(String, Regex, Regex, Box)>, /// Maximum size of call arguments pub maximum_call_argument_size: u32, /// Maximum execution budget of a read-only call @@ -1026,14 +1062,26 @@ impl StacksHttp { } } - /// Register an API RPC endpoint + /// Register an API RPC endpoint. + /// Auto-generates a permissive regex for 400/404/405 detection + /// unless the handler provides its own via path_regex_permissive(). pub fn register_rpc_endpoint( &mut self, handler: Handler, ) { + let strict_regex = handler.path_regex(); + let permissive_regex = handler.path_regex_permissive(); + + let permissive_regex = if permissive_regex.as_str() == strict_regex.as_str() { + make_permissive_regex(&strict_regex) + } else { + permissive_regex + }; + self.request_handlers.push(( handler.verb().to_string(), - handler.path_regex(), + strict_regex, + permissive_regex, Box::new(handler), )); } @@ -1041,7 +1089,7 @@ impl StacksHttp { /// Find the HTTP request handler to use to process the reply, given the request path. /// Returns the index into the list of handlers fn find_response_handler(&self, request_verb: &str, request_path: &str) -> Option { - for (i, (verb, regex, _)) in self.request_handlers.iter().enumerate() { + for (i, (verb, regex, _, _)) in self.request_handlers.iter().enumerate() { if request_verb != verb { continue; } @@ -1054,6 +1102,21 @@ impl StacksHttp { None } + /// Find all allowed HTTP methods for a given path. + /// Returns a list of HTTP verbs that are allowed for handlers whose path regex matches. + fn find_allowed_methods(&self, request_path: &str) -> Vec { + let mut allowed_methods = Vec::new(); + for (verb, regex, permissive_regex, _) in self.request_handlers.iter() { + // Check if either the strict or permissive regex matches + if regex.is_match(request_path) || permissive_regex.is_match(request_path) { + if !allowed_methods.contains(verb) { + allowed_methods.push(verb.clone()); + } + } + } + allowed_methods + } + /// Force the state machine to expect a response #[cfg(test)] pub fn set_response_handler(&mut self, request_verb: &str, request_path: &str) { @@ -1108,17 +1171,28 @@ impl StacksHttp { let (decoded_path, query) = decode_request_path(&preamble.path_and_query_str)?; test_debug!("decoded_path: '{}', query: '{}'", &decoded_path, &query); - // NOTE: This loop starts out like `find_response_handler()`, but `captures`'s lifetime is - // bound to `regex` so we can't just return it from `find_response_handler()`. Thus, it's - // duplicated here. - for (verb, regex, request) in self.request_handlers.iter_mut() { - if &preamble.verb != verb { - continue; - } + let mut allowed_methods: Vec = Vec::new(); + let mut verb_matched_but_params_invalid = false; + let mut any_strict_match = false; + + for (verb, regex, permissive_regex, request) in self.request_handlers.iter_mut() { + let permissive_match = permissive_regex.is_match(&decoded_path); let Some(captures) = regex.captures(&decoded_path) else { + if permissive_match { + if &preamble.verb == verb { + verb_matched_but_params_invalid = true; + } + allowed_methods.push(verb.to_string()); + } continue; }; + any_strict_match = true; + allowed_methods.push(verb.to_string()); + if &preamble.verb != verb { + continue; + } + let payload = match request.try_parse_request( preamble, &captures, @@ -1133,15 +1207,29 @@ impl StacksHttp { }; debug!("Handle StacksHttpRequest"; "verb" => %verb, "peer_addr" => %self.peer_addr, "path" => %decoded_path, "query" => %query); - let request = StacksHttpRequest::new(preamble.clone(), payload); - return Ok(request); + return Ok(StacksHttpRequest::new(preamble.clone(), payload)); } test_debug!("Failed to parse '{}'", &preamble.path_and_query_str); - Err(NetError::Http(HttpError::Http( - 404, - "No such file or directory".into(), - ))) + + // 400 if path pattern matched but params invalid (e.g. GET /v3/blocks/65-char-hex) + // 405 if path exists but wrong method (e.g. DELETE /v2/info) + // 404 if path doesn't exist + if !any_strict_match && verb_matched_but_params_invalid { + Err(NetError::Http(HttpError::Http( + 400, + format!("Invalid path parameters for '{}'", &decoded_path), + ))) + } else if !allowed_methods.is_empty() { + Err(NetError::Http(HttpError::HttpMethodNotAllowed( + allowed_methods, + ))) + } else { + Err(NetError::Http(HttpError::Http( + 404, + "No such file or directory".into(), + ))) + } } /// Parse out an HTTP response error message @@ -1194,7 +1282,7 @@ impl StacksHttp { return Self::try_parse_error_response(preamble, body); } - let (_, _, parser) = self + let (_, _, _, parser) = self .request_handlers .get(request_handler_index) .expect("FATAL: tried to use nonexistent response handler"); @@ -1217,7 +1305,20 @@ impl StacksHttp { .response_handler_index .or_else(|| self.find_response_handler(&request.preamble().verb, &decoded_path)) else { - // method not found + // Handler not found - check if it's a method not allowed (405) or not found (404) + let allowed_methods = self.find_allowed_methods(&decoded_path); + if !allowed_methods.is_empty() { + // Path exists but method is not allowed (405) + let error = HttpMethodNotAllowed::with_allowed_methods(allowed_methods); + let allowed_str = error.get_allowed_methods().join(", "); + return StacksHttpResponse::new_error_with_headers( + &request.preamble, + &error, + vec![("Allow".to_string(), allowed_str)], + ) + .try_into_contents(); + } + // Path does not exist (404) return StacksHttpResponse::new_error( &request.preamble, &HttpNotFound::new(format!( @@ -1229,7 +1330,7 @@ impl StacksHttp { .try_into_contents(); }; - let (_, _, request_handler) = self + let (_, _, _, request_handler) = self .request_handlers .get_mut(response_handler_index) .expect("FATAL: request points to a nonexistent handler"); @@ -1385,7 +1486,7 @@ impl StacksHttp { }; req.response_handler_index = Some(response_handler_index); - let (_, _, request_handler) = self + let (_, _, _, request_handler) = self .request_handlers .get(response_handler_index) .expect("FATAL: request points to a nonexistent handler"); @@ -1627,9 +1728,17 @@ impl ProtocolFamily for StacksHttp { Ok(data_request) => Ok((StacksHttpMessage::Request(data_request), len)), Err(NetError::Http(http_error)) => { // convert into a response - let resp = StacksHttpResponse::new_error( + // for 405 responses, use the structured allowed methods + let extra_headers = + if let HttpError::HttpMethodNotAllowed(ref methods) = &http_error { + vec![("Allow".to_string(), methods.join(", "))] + } else { + vec![] + }; + let resp = StacksHttpResponse::new_error_with_headers( http_request_preamble, &*http_error.into_http_error(), + extra_headers, ); self.reset(); return Ok(( @@ -1950,7 +2059,9 @@ pub fn send_http_request( if last_heartbeat_time.elapsed() >= HEARTBEAT_INTERVAL { info!( "send_request(sending data): heartbeat - still sending request to {} path='{}' (elapsed: {:?})", - addr, request_path, start.elapsed() + addr, + request_path, + start.elapsed() ); last_heartbeat_time = Instant::now(); } @@ -2002,7 +2113,9 @@ pub fn send_http_request( if last_heartbeat_time.elapsed() >= HEARTBEAT_INTERVAL { info!( "send_request(receiving data): heartbeat - still receiving response from {} path='{}' (elapsed: {:?})", - addr, request_path, start.elapsed() + addr, + request_path, + start.elapsed() ); last_heartbeat_time = Instant::now(); } @@ -2016,11 +2129,9 @@ pub fn send_http_request( let path = &request.preamble().path_and_query_str; let resp_status_code = response.preamble().status_code; let resp_body = response.body(); - return Err(io::Error::other( - format!( - "HTTP '{verb} {path}' did not succeed ({resp_status_code} != 200). Response body = {resp_body:?}" - ), - )); + return Err(io::Error::other(format!( + "HTTP '{verb} {path}' did not succeed ({resp_status_code} != 200). Response body = {resp_body:?}" + ))); } _ => { return Err(io::Error::other("Did not receive an HTTP response"));