282 lines
18 KiB
Bash
Executable File
282 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"
|
|
|
|
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"
|