Files
kaidi/scripts/check-openapi.sh
Qiufeng 12d78b4c86
Release / release (push) Canceled after 0s
feat: add system operations dashboard
2026-08-19 13:51:48 +08:00

291 lines
18 KiB
Bash
Executable File

#!/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"