#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SPEC_FILE="${1:-$ROOT_DIR/openapi.yaml}" # shellcheck disable=SC2016 # The single-quoted block is Ruby source, not shell text. ruby -ryaml -e ' document = YAML.load_file(ARGV.fetch(0)) paths = document.fetch("paths") finance_lists = { "/api/v1/contracts" => "listFinanceContracts", "/api/v1/costs" => "listFinanceCosts", "/api/v1/payables" => "listFinancePayables" } finance_lists.each do |path, operation_id| operation = paths.fetch(path).fetch("get") abort "#{path} has unstable operationId" unless operation["operationId"] == operation_id sort_parameter = operation.fetch("parameters").find { |parameter| parameter["name"] == "sort" } abort "#{path} is missing repeatable sort schema" unless sort_parameter&.dig("schema", "type") == "array" end rescan_operations = { ["/api/v1/files/{publicId}/rescan", "post"] => "rescanOwnedFile", ["/api/v1/archive/files/{publicId}/rescan", "post"] => "rescanArchiveFile" } rescan_operations.each do |(path, method), operation_id| actual = paths.fetch(path).fetch(method).fetch("operationId") abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id end fund_ledger = paths.fetch("/api/v1/funds/ledger").fetch("get") abort "fund ledger has unstable operationId" unless fund_ledger["operationId"] == "listFundLedger" fund_parameter_names = fund_ledger.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort expected_fund_parameters = %w[currency dateFrom dateTo entryType page projectId size sortBy sortDirection] abort "fund ledger parameter contract drifted: #{fund_parameter_names.inspect}" unless fund_parameter_names == expected_fund_parameters workflow_operations = { ["/api/v1/workflow/tasks", "get"] => "listWorkflowTasks", ["/api/v1/workflow/tasks/{publicId}", "get"] => "getWorkflowTask", ["/api/v1/workflow/tasks/{publicId}/approve", "post"] => "approveWorkflowTask", ["/api/v1/workflow/tasks/{publicId}/return", "post"] => "returnWorkflowTask", ["/api/v1/workflow/tasks/{publicId}/reject", "post"] => "rejectWorkflowTask", ["/api/v1/workflow/instances/{instancePublicId}/withdraw", "post"] => "withdrawWorkflowInstance", ["/api/v1/workflow/instances/{instancePublicId}/void", "post"] => "voidWorkflowInstance" } workflow_operations.each do |(path, method), operation_id| actual = paths.fetch(path).fetch(method).fetch("operationId") abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id end workflow_list = paths.fetch("/api/v1/workflow/tasks").fetch("get") workflow_parameters = workflow_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort expected_workflow_parameters = %w[ arrivedDateFrom arrivedDateTo formType keyword overdue page projectId size sort status view ] abort "workflow task parameter contract drifted: #{workflow_parameters.inspect}" unless workflow_parameters == expected_workflow_parameters workflow_sort = workflow_list.fetch("parameters").find { |parameter| parameter["name"] == "sort" } expected_workflow_sorts = %w[arrivedAt,asc arrivedAt,desc dueAt,asc dueAt,desc updatedAt,asc updatedAt,desc] abort "workflow task sort whitelist drifted" unless workflow_sort&.dig("schema", "enum") == expected_workflow_sorts workflow_size = workflow_list.fetch("parameters").find { |parameter| parameter["name"] == "size" } abort "workflow task size schema drifted" unless workflow_size&.dig("schema", "type") == "integer" abort "workflow task size whitelist drifted" unless workflow_size&.dig("schema", "enum") == [20, 50, 100] source_forms = paths.fetch("/api/v1/source/forms").fetch("get") abort "source forms has unstable operationId" unless source_forms["operationId"] == "listSourceForms" source_form_parameters = source_forms.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort expected_source_form_parameters = %w[ createdByMe createdDateFrom createdDateTo formType groupCode keyword page projectId size sort sourceSystem status templateVersion ] abort "source forms parameter contract drifted: #{source_form_parameters.inspect}" unless source_form_parameters == expected_source_form_parameters source_form_sort = source_forms.fetch("parameters").find { |parameter| parameter["name"] == "sort" } expected_source_form_sorts = %w[ updatedAt,asc updatedAt,desc createdAt,asc createdAt,desc businessNo,asc businessNo,desc status,asc status,desc ] abort "source forms sort whitelist drifted" unless source_form_sort&.dig("schema", "enum") == expected_source_form_sorts source_form_size = source_forms.fetch("parameters").find { |parameter| parameter["name"] == "size" } abort "source forms size whitelist drifted" unless source_form_size&.dig("schema", "enum") == [20, 50, 100] accounting_operations = { ["/api/v1/accounting/accounts", "get"] => "listAccountingAccounts", ["/api/v1/accounting/events", "get"] => "listAccountingEvents", ["/api/v1/accounting/events/{publicId}/generate-draft", "post"] => "generateVoucherDraft", ["/api/v1/accounting/vouchers", "get"] => "listVouchers", ["/api/v1/accounting/vouchers/{publicId}", "get"] => "getVoucher", ["/api/v1/accounting/vouchers/{publicId}", "patch"] => "updateVoucher", ["/api/v1/accounting/vouchers/{publicId}/submit", "post"] => "submitVoucher", ["/api/v1/accounting/vouchers/{publicId}/approve", "post"] => "approveVoucher", ["/api/v1/accounting/vouchers/{publicId}/return", "post"] => "returnVoucher", ["/api/v1/accounting/vouchers/{publicId}/export", "post"] => "exportVoucher", ["/api/v1/accounting/vouchers/{publicId}/export-file", "get"] => "downloadVoucherExport", ["/api/v1/accounting/vouchers/{voucherPublicId}/result-files/{filePublicId}/content", "get"] => "downloadVoucherResultEvidence", ["/api/v1/accounting/vouchers/{publicId}/record-result", "post"] => "recordVoucherResult", ["/api/v1/accounting/vouchers/{publicId}/verify-result", "post"] => "verifyVoucherResult", ["/api/v1/accounting/vouchers/{publicId}/reverse-result", "post"] => "reverseVoucherResult", ["/api/v1/accounting/vouchers/{publicId}/reopen-result", "post"] => "reopenVoucherResult", ["/api/v1/accounting/vouchers/{publicId}/void", "post"] => "voidVoucher" } accounting_operations.each do |(path, method), operation_id| actual = paths.fetch(path).fetch(method).fetch("operationId") abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id end audit_operations = { ["/api/v1/audit/logs", "get"] => "listAuditLogs", ["/api/v1/audit/logs/{publicId}", "get"] => "getAuditLog", ["/api/v1/audit/exports", "post"] => "exportAuditLogs" } audit_operations.each do |(path, method), operation_id| actual = paths.fetch(path).fetch(method).fetch("operationId") abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id end audit_list = paths.fetch("/api/v1/audit/logs").fetch("get") audit_parameters = audit_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort expected_audit_parameters = %w[ action actorId identityCode keyword objectId objectType occurredFrom occurredTo page requestId result size sort ] abort "audit list parameter contract drifted: #{audit_parameters.inspect}" unless audit_parameters == expected_audit_parameters audit_result = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "result" } expected_audit_results = %w[SUCCESS FAILED DENIED BLOCKED] abort "audit result whitelist drifted" unless audit_result&.dig("schema", "enum") == expected_audit_results audit_size = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "size" } abort "audit size whitelist drifted" unless audit_size&.dig("schema", "enum") == [20, 50, 100] audit_actor = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "actorId" } abort "audit actorId length drifted" unless audit_actor&.dig("schema", "maxLength") == 26 audit_sort = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "sort" } expected_audit_sorts = %w[occurredAt,asc occurredAt,desc eventSequence,asc eventSequence,desc] abort "audit sort whitelist drifted" unless audit_sort&.dig("schema", "enum") == expected_audit_sorts audit_response_ref = lambda { |operation| operation.dig("responses", "200", "content", "application/json", "schema", "$ref") } abort "audit list response drifted" unless audit_response_ref.call(audit_list) == "#/components/schemas/ApiResponseListAuditLogView" abort "audit detail response drifted" unless audit_response_ref.call( paths.fetch("/api/v1/audit/logs/{publicId}").fetch("get") ) == "#/components/schemas/ApiResponseAuditLogView" abort "audit export response drifted" unless audit_response_ref.call( paths.fetch("/api/v1/audit/exports").fetch("post") ) == "#/components/schemas/ApiResponseAuditExportView" problem_ref = "#/components/schemas/ApiProblem" problem_response_ref = lambda { |operation, code| operation.dig("responses", code, "content", "application/problem+json", "schema", "$ref") } { audit_list => %w[400 401 403 422], paths.fetch("/api/v1/audit/logs/{publicId}").fetch("get") => %w[401 403 404], paths.fetch("/api/v1/audit/exports").fetch("post") => %w[400 401 403 422] }.each do |operation, codes| codes.each do |code| abort "audit problem response #{code} drifted" unless problem_response_ref.call(operation, code) == problem_ref end end versions = paths.fetch("/api/v1/masterdata/{resource}/{publicId}/versions").fetch("get") abort "master-data versions has unstable operationId" unless versions["operationId"] == "listMasterDataVersions" versions_size = versions.fetch("parameters").find { |parameter| parameter["name"] == "size" } abort "master-data versions size whitelist drifted" unless versions_size&.dig("schema", "enum") == [20, 50, 100] %w[400 401 403 404 422].each do |code| abort "master-data versions problem response #{code} drifted" unless problem_response_ref.call(versions, code) == problem_ref end archive_operations = { ["/api/v1/archive/packages", "get"] => "listArchivePackages", ["/api/v1/archive/packages/{publicId}", "get"] => "getArchivePackage", ["/api/v1/archive/packages", "post"] => "createArchivePackage", ["/api/v1/archive/packages/{publicId}/check", "post"] => "checkArchivePackage", ["/api/v1/archive/packages/{publicId}/submit", "post"] => "submitArchivePackage", ["/api/v1/archive/packages/{publicId}/return", "post"] => "returnArchivePackage", ["/api/v1/archive/packages/{publicId}/revise", "post"] => "reviseArchivePackage", ["/api/v1/archive/packages/{publicId}/archive", "post"] => "archivePackage", ["/api/v1/archive/packages/{publicId}/freeze", "post"] => "freezeArchivePackage", ["/api/v1/archive/packages/{publicId}/unfreeze", "post"] => "unfreezeArchivePackage", ["/api/v1/archive/packages/{packageId}/items/{itemId}/not-applicable", "post"] => "markArchiveItemNotApplicable", ["/api/v1/archive/packages/{packageId}/items/{itemId}/not-applicable/review", "post"] => "reviewArchiveItemNotApplicable", ["/api/v1/archive/files", "get"] => "listArchiveFiles", ["/api/v1/archive/files", "post"] => "uploadArchiveFile", ["/api/v1/archive/files/{publicId}", "get"] => "getArchiveFile", ["/api/v1/archive/files/{publicId}/rescan", "post"] => "rescanArchiveFile", ["/api/v1/archive/files/{publicId}/content", "get"] => "getArchiveFileContent", ["/api/v1/archive/borrows", "get"] => "listBorrows", ["/api/v1/archive/borrows", "post"] => "createBorrow", ["/api/v1/archive/borrows/{publicId}/approve", "post"] => "approveBorrow", ["/api/v1/archive/borrows/{publicId}/reject", "post"] => "rejectBorrow", ["/api/v1/archive/borrows/{publicId}/return", "post"] => "returnBorrow" } archive_operations.each do |(path, method), operation_id| actual = paths.fetch(path).fetch(method).fetch("operationId") abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id end project_operations = { ["/api/v1/projects/{publicId}/finance-complete", "post"] => "completeProjectFinance", ["/api/v1/projects/{projectPublicId}/forms", "get"] => "getProjectForms", ["/api/v1/projects/{projectPublicId}/contracts", "get"] => "getProjectContracts", ["/api/v1/projects/{projectPublicId}/receipts", "get"] => "getProjectReceipts", ["/api/v1/projects/{projectPublicId}/invoices", "get"] => "getProjectInvoices", ["/api/v1/projects/{projectPublicId}/payments", "get"] => "getProjectPayments", ["/api/v1/projects/{projectPublicId}/progress", "get"] => "getProjectProgress", ["/api/v1/projects/{projectPublicId}/people", "get"] => "getProjectPeople", ["/api/v1/projects/{projectPublicId}/accounting", "get"] => "getProjectAccounting", ["/api/v1/projects/{projectPublicId}/archives", "get"] => "getProjectArchives", ["/api/v1/projects/{projectPublicId}/timeline", "get"] => "getProjectTimeline", ["/api/v1/projects/{projectPublicId}/risk-flags", "get"] => "getProjectRiskFlags" } project_operations.each do |(path, method), operation_id| actual = paths.fetch(path).fetch(method).fetch("operationId") abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id end governance_operations = { ["/api/v1/admin/{resource}", "get"] => "listGovernanceResources", ["/api/v1/admin/{resource}/{publicId}", "get"] => "getGovernanceResource", ["/api/v1/admin/{resource}", "post"] => "createGovernanceResource", ["/api/v1/admin/{resource}/{publicId}", "patch"] => "updateGovernanceResource", ["/api/v1/admin/{resource}/{publicId}/{command}", "post"] => "commandGovernanceResource" } governance_operations.each do |(path, method), operation_id| actual = paths.fetch(path).fetch(method).fetch("operationId") abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id end update_operations = { ["/api/v1/admin/system-update", "get"] => "getSystemUpdateStatus", ["/api/v1/admin/system-update/check", "post"] => "checkSystemUpdate", ["/api/v1/admin/system-update/download", "post"] => "downloadSystemUpdate", ["/api/v1/admin/system-update/install", "post"] => "installSystemUpdate" } update_operations.each do |(path, method), operation_id| actual = paths.fetch(path).fetch(method).fetch("operationId") abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id end update_response = paths.fetch("/api/v1/admin/system-update").fetch("get") .dig("responses", "200", "content", "application/json", "schema", "$ref") abort "system update response drifted" unless update_response == "#/components/schemas/ApiResponseSystemUpdateView" dashboard = paths.fetch("/api/v1/dashboard/overview").fetch("get") abort "dashboard operationId drifted" unless dashboard["operationId"] == "getDashboardOverview" dashboard_currency = dashboard.fetch("parameters").find { |parameter| parameter["name"] == "currency" } abort "dashboard currency whitelist drifted" unless dashboard_currency&.dig("schema", "enum") == %w[CNY USD EUR HKD JPY GBP] dashboard_response = dashboard.dig("responses", "200", "content", "application/json", "schema", "$ref") abort "dashboard response drifted" unless dashboard_response == "#/components/schemas/ApiResponseDashboardOverviewView" governance_list = paths.fetch("/api/v1/admin/{resource}").fetch("get") parameter_names = governance_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort expected_parameter_names = %w[keyword page resource size] abort "governance list parameter whitelist drifted: #{parameter_names.inspect}" unless parameter_names == expected_parameter_names resource_parameter = governance_list.fetch("parameters").find { |parameter| parameter["name"] == "resource" } resource_values = resource_parameter&.dig("schema", "enum") expected_resources = %w[users roles scopes templates parameters] abort "governance resource enum drifted: #{resource_values.inspect}" unless resource_values == expected_resources schemas = document.fetch("components", {}).fetch("schemas", {}) abort "internal query map leaked into OpenAPI" if schemas.key?("MultiValueMapStringString") task_properties = schemas.fetch("WorkflowTaskSummaryView").fetch("properties") abort "workflow keyAmount must remain a decimal string" unless task_properties.dig("keyAmount", "type") == "string" list_response_ref = governance_list.dig("responses", "200", "content", "application/json", "schema", "$ref") expected_list_response_ref = "#/components/schemas/ApiResponseListResourceListView" abort "governance list response drifted: #{list_response_ref.inspect}" unless list_response_ref == expected_list_response_ref list_data_ref = schemas.dig("ApiResponseListResourceListView", "properties", "data", "items", "$ref") abort "governance list data is not ResourceListView" unless list_data_ref == "#/components/schemas/ResourceListView" resource_properties = schemas.fetch("ResourceListView").fetch("properties") expected_resource_fields = %w[ resource publicId username displayName departmentName enabled mustChangePassword roleCodes code name description sortOrder memberCount userPublicId roleCode permissionCode scopeType companyPublicId projectPublicId amountLimit status formType templateVersion schemaVersion parameterGroup versionNo version publishedAt effectiveAt createdAt updatedAt ] missing_fields = expected_resource_fields - resource_properties.keys extra_fields = resource_properties.keys - expected_resource_fields abort "ResourceListView fields drifted; missing=#{missing_fields.inspect}, extra=#{extra_fields.inspect}" unless missing_fields.empty? && extra_fields.empty? abort "amountLimit must remain a decimal string" unless resource_properties.dig("amountLimit", "type") == "string" abort "roleCodes must remain a string array" unless resource_properties.dig("roleCodes", "type") == "array" && resource_properties.dig("roleCodes", "items", "type") == "string" %w[publishedAt effectiveAt createdAt updatedAt].each do |field| schema = resource_properties.fetch(field) abort "#{field} must remain an ISO date-time string" unless schema["type"] == "string" && schema["format"] == "date-time" end puts "OpenAPI contract checks passed" ' "$SPEC_FILE"