-
Notifications
You must be signed in to change notification settings - Fork 20
feat(api): validate request fields before rpc calls #497
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f7b60db
08316b9
08c45e0
ca68941
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ import ballerina/io; | |
| import ballerina/lang.'int as langint; | ||
| import ballerina/grpc; | ||
| import ballerina/log; | ||
| import ballerina/constraint; | ||
|
|
||
| // BAL_CONFIG_VAR_CORESERVICEURL | ||
| configurable string coreServiceUrl = "http://localhost:50051"; | ||
|
|
@@ -34,6 +35,249 @@ grpc:ClientConfiguration grpcConfig = { | |
|
|
||
| COREServiceClient ep = check new (coreServiceUrl, grpcConfig); | ||
|
|
||
| // Request validation uses the `ballerina/constraint` standard library. | ||
| // `constraint:validate` clones the untyped JSON payload into the constrained | ||
| // record types below and rejects missing fields, wrong types and format | ||
| // violations with an error before any gRPC call is made. | ||
| @constraint:String { | ||
| pattern: {value: re `.*\S.*`, message: "Entity id is required"} | ||
| } | ||
| type EntityIdParam string; | ||
|
|
||
| @constraint:String { | ||
| pattern: { | ||
| value: re `\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?`, | ||
| message: "Invalid date-time format, expected YYYY-MM-DD or RFC3339" | ||
| } | ||
| } | ||
| type OptionalDateTimeString string; | ||
|
|
||
| type EntityKindPayload record { | ||
| @constraint:String {minLength: {value: 1, message: "Kind.major is required"}} | ||
| string major; | ||
| @constraint:String {minLength: {value: 1, message: "Kind.minor is required"}} | ||
| string minor; | ||
| }; | ||
|
|
||
| type EntityNamePayload record { | ||
| json value; | ||
| OptionalDateTimeString startTime?; | ||
| OptionalDateTimeString endTime?; | ||
| }; | ||
|
|
||
| type EntityCreatePayload record { | ||
| @constraint:String { | ||
| minLength: {value: 1, message: "Entity id is required"}, | ||
| pattern: {value: re `.*\S.*`, message: "Entity id is required"} | ||
| } | ||
| string id; | ||
| EntityKindPayload kind; | ||
| @constraint:String { | ||
| minLength: {value: 1, message: "Created is required"}, | ||
| pattern: { | ||
| value: re `\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?`, | ||
| message: "Created has an invalid format, expected YYYY-MM-DD or RFC3339" | ||
| } | ||
| } | ||
| string created; | ||
| OptionalDateTimeString terminated?; | ||
| EntityNamePayload name; | ||
| json metadata?; | ||
| json attributes?; | ||
| json relationships?; | ||
| }; | ||
|
|
||
| type RelationshipPayload record { | ||
| @constraint:String {minLength: {value: 1, message: "relationship id is required"}} | ||
| string id; | ||
| @constraint:String {minLength: {value: 1, message: "relationship relatedEntityId is required"}} | ||
| string relatedEntityId; | ||
| @constraint:String {minLength: {value: 1, message: "relationship name is required"}} | ||
| string name; | ||
| @constraint:String { | ||
| minLength: {value: 1, message: "relationship startTime is required"}, | ||
| pattern: { | ||
| value: re `\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?`, | ||
| message: "relationship startTime has an invalid format, expected YYYY-MM-DD or RFC3339" | ||
| } | ||
| } | ||
| string startTime; | ||
| OptionalDateTimeString endTime?; | ||
| }; | ||
|
|
||
| type RelationshipUpdatePayload record { | ||
| @constraint:String {minLength: {value: 1, message: "relationship id is required"}} | ||
| string id; | ||
| string relatedEntityId?; | ||
| string name?; | ||
| OptionalDateTimeString startTime?; | ||
| OptionalDateTimeString endTime?; | ||
| }; | ||
|
|
||
| type AttributeValuePayload record { | ||
| @constraint:String { | ||
| minLength: {value: 1, message: "attribute startTime is required"}, | ||
| pattern: { | ||
| value: re `\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?`, | ||
| message: "attribute startTime has an invalid format, expected YYYY-MM-DD or RFC3339" | ||
| } | ||
| } | ||
| string startTime; | ||
| OptionalDateTimeString endTime?; | ||
| json value; | ||
| }; | ||
|
|
||
| type EntityUpdatePayload record { | ||
| string id?; | ||
| record {string major?; string minor?;} kind?; | ||
| string created?; | ||
| string terminated?; | ||
| record {json value?; string startTime?; string endTime?;} name?; | ||
| json metadata?; | ||
| json attributes?; | ||
| json relationships?; | ||
| }; | ||
|
|
||
| function validateNewRelationships(json rels) returns string? { | ||
| if rels is json[] { | ||
| foreach json item in rels { | ||
| if item is map<json> { | ||
| json|error rawKey = item.key; | ||
| if rawKey is error || rawKey.toString().trim().length() == 0 { | ||
|
Comment on lines
+145
to
+146
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Reject blank keys in map-form relationships and attributes. The map branches in 🤖 Prompt for AI Agents |
||
| return "relationship entry key is required"; | ||
| } | ||
| RelationshipPayload|error rel = constraint:validate(item["value"]); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if rel is error { | ||
| return "invalid relationship: " + rel.message(); | ||
| } | ||
| } else { | ||
| return "relationship entry must be a JSON object"; | ||
| } | ||
| } | ||
| return; | ||
| } | ||
| if rels is map<json> { | ||
| foreach var [key, val] in rels.entries() { | ||
| if val is map<json> { | ||
| RelationshipPayload|error rel = constraint:validate(val); | ||
| if rel is error { | ||
| return "invalid relationship '" + key + "': " + rel.message(); | ||
| } | ||
| } else { | ||
| return "relationship '" + key + "' must be a JSON object"; | ||
| } | ||
| } | ||
| return; | ||
| } | ||
| return "relationships must be an array or an object"; | ||
| } | ||
|
|
||
| function validateUpdateRelationships(json rels) returns string? { | ||
| if rels is json[] { | ||
| foreach json item in rels { | ||
| if item is map<json> { | ||
| json|error rawKey = item.key; | ||
| if rawKey is error || rawKey.toString().trim().length() == 0 { | ||
| return "relationship entry key is required"; | ||
| } | ||
| RelationshipUpdatePayload|error rel = constraint:validate(item["value"]); | ||
| if rel is error { | ||
| return "invalid relationship: " + rel.message(); | ||
| } | ||
| } else { | ||
| return "relationship entry must be a JSON object"; | ||
| } | ||
| } | ||
| return; | ||
| } | ||
| if rels is map<json> { | ||
| foreach var [key, val] in rels.entries() { | ||
| if val is map<json> { | ||
| RelationshipUpdatePayload|error rel = constraint:validate(val); | ||
| if rel is error { | ||
| return "invalid relationship '" + key + "': " + rel.message(); | ||
| } | ||
| } else { | ||
| return "relationship '" + key + "' must be a JSON object"; | ||
| } | ||
| } | ||
| return; | ||
| } | ||
| return "relationships must be an array or an object"; | ||
| } | ||
|
|
||
| function validateAttributeValue(json val) returns string? { | ||
| if val is json[] { | ||
| foreach json valueItem in val { | ||
| AttributeValuePayload|error tbv = constraint:validate(valueItem); | ||
| if tbv is error { | ||
| return tbv.message(); | ||
| } | ||
| } | ||
| return; | ||
| } | ||
| if val is map<json> { | ||
| if !(val["values"] is ()) { | ||
| return validateAttributeValue(val["values"]); | ||
| } | ||
| AttributeValuePayload|error tbv = constraint:validate(val); | ||
| if tbv is error { | ||
| return tbv.message(); | ||
| } | ||
| return; | ||
| } | ||
| return "attribute value must be an array or an object"; | ||
| } | ||
|
|
||
| function validateAttributeValues(json attrs) returns string? { | ||
| if attrs is json[] { | ||
| foreach json item in attrs { | ||
| if item is map<json> { | ||
| json|error rawKey = item.key; | ||
| if rawKey is error || rawKey.toString().trim().length() == 0 { | ||
|
Comment on lines
+236
to
+237
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Support direct
🤖 Prompt for AI Agents |
||
| return "attribute entry key is required"; | ||
| } | ||
| string? err = validateAttributeValue(item["value"]); | ||
| if err is string { | ||
| return err; | ||
| } | ||
| } else { | ||
| return "attribute entry must be a JSON object"; | ||
| } | ||
| } | ||
| return; | ||
| } | ||
| if attrs is map<json> { | ||
| foreach var [key, val] in attrs.entries() { | ||
| string? err = validateAttributeValue(val); | ||
| if err is string { | ||
| return "invalid attribute '" + key + "': " + err; | ||
| } | ||
| } | ||
| return; | ||
| } | ||
| return "attributes must be an array or an object"; | ||
| } | ||
|
|
||
| function validateUpdatePayload(string urlId, EntityUpdatePayload payload) returns string? { | ||
| string? bodyId = payload?.id; | ||
| if bodyId is string && bodyId.trim() != "" && bodyId != urlId { | ||
| return "Entity id in payload must match id in path"; | ||
| } | ||
| record {string major?; string minor?;}? kind = payload?.kind; | ||
| if kind is record {string major?; string minor?;} { | ||
| string? major = kind.major; | ||
| string? minor = kind.minor; | ||
| if major is string && major.trim() != "" { | ||
| return "Kind cannot be updated"; | ||
| } | ||
| if minor is string && minor.trim() != "" { | ||
| return "Kind cannot be updated"; | ||
| } | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| // Helper function to convert decimal values to float for protobuf compatibility | ||
| // Note that this is a temporary solution to convert decimal values to float for protobuf compatibility. | ||
| // It is not a permanent solution and should be removed when the protobuf library is updated to support decimal values. | ||
|
|
@@ -116,7 +360,7 @@ service / on ep0 { | |
| # Delete an entity | ||
| # | ||
| # + return - Entity deleted | ||
| resource function delete entities/[string id]() returns http:NoContent|error { | ||
| resource function delete entities/[EntityIdParam id]() returns http:NoContent|http:BadRequest|error { | ||
| var result = ep->DeleteEntity({id: id}); | ||
| if result is error { | ||
| io:println("gRPC DeleteEntity failed: ", result.message()); | ||
|
|
@@ -128,7 +372,34 @@ service / on ep0 { | |
| # Create a new entity | ||
| # | ||
| # + return - Entity created | ||
| resource function post entities(@http:Payload json jsonPayload) returns Entity|error { | ||
| resource function post entities(@http:Payload json jsonPayload) returns Entity|http:BadRequest|error { | ||
| EntityCreatePayload|error validated = constraint:validate(jsonPayload); | ||
| if validated is error { | ||
| return <http:BadRequest>{ | ||
| body: { | ||
| "error": "Invalid request parameters", | ||
| "details": validated.message() | ||
| } | ||
| }; | ||
| } | ||
| string? relError = validateNewRelationships(validated?.relationships ?: []); | ||
| if relError is string { | ||
| return <http:BadRequest>{ | ||
| body: { | ||
| "error": "Invalid request parameters", | ||
| "details": relError | ||
| } | ||
| }; | ||
| } | ||
| string? attrError = validateAttributeValues(validated?.attributes ?: []); | ||
| if attrError is string { | ||
| return <http:BadRequest>{ | ||
| body: { | ||
| "error": "Invalid request parameters", | ||
| "details": attrError | ||
| } | ||
| }; | ||
| } | ||
| // Convert JSON to Entity with custom mapping | ||
| io:println("[CreateEntity] jsonPayload: ", jsonPayload); | ||
| Entity payload = check convertJsonToEntity(jsonPayload); | ||
|
|
@@ -145,7 +416,43 @@ service / on ep0 { | |
| # Update an existing entity | ||
| # | ||
| # + return - Entity updated | ||
| resource function put entities/[string id](@http:Payload json jsonPayload) returns Entity|error { | ||
| resource function put entities/[EntityIdParam id](@http:Payload json jsonPayload) returns Entity|http:BadRequest|error { | ||
| EntityUpdatePayload|error validated = constraint:validate(jsonPayload); | ||
| if validated is error { | ||
| return <http:BadRequest>{ | ||
| body: { | ||
| "error": "Invalid request parameters", | ||
| "details": validated.message() | ||
| } | ||
| }; | ||
| } | ||
| string? semanticError = validateUpdatePayload(id, validated); | ||
| if semanticError is string { | ||
| return <http:BadRequest>{ | ||
| body: { | ||
| "error": "Invalid request parameters", | ||
| "details": semanticError | ||
| } | ||
| }; | ||
| } | ||
| string? relError = validateUpdateRelationships(validated?.relationships ?: []); | ||
| if relError is string { | ||
| return <http:BadRequest>{ | ||
| body: { | ||
| "error": "Invalid request parameters", | ||
| "details": relError | ||
| } | ||
| }; | ||
| } | ||
| string? attrError = validateAttributeValues(validated?.attributes ?: []); | ||
| if attrError is string { | ||
| return <http:BadRequest>{ | ||
| body: { | ||
| "error": "Invalid request parameters", | ||
| "details": attrError | ||
| } | ||
| }; | ||
| } | ||
| // Convert JSON to Entity with custom mapping | ||
| Entity payload = check convertJsonToEntity(jsonPayload); | ||
|
|
||
|
|
@@ -168,7 +475,7 @@ service / on ep0 { | |
| # | ||
| # + id - The ID of the entity to retrieve | ||
| # + return - The entity or an error | ||
| resource function get entities/[string id]() returns Entity|error { | ||
| resource function get entities/[EntityIdParam id]() returns Entity|http:BadRequest|error { | ||
| // Call the ReadEntity function with the ID | ||
| ReadEntityRequest readEntityRequest = { | ||
| entity: { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Preserve the empty-string timestamp contract.
The enforced REST test sends
terminated: ""andname.endTime: ""and expects HTTP 201.OptionalDateTimeStringrejects both values duringconstraint:validate, so the request returns HTTP 400 before the gRPC call. The OpenAPI schema marks these fields nullable but does not define empty strings. Either accept""as the existing absence marker, or migrate the test and callers to omit absent timestamps and update the contract.🤖 Prompt for AI Agents
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Validate
OptionalDateTimeStringvalues semantically before creationOptionalDateTimeStringis reachable through thePOST /entitiescreate path. Its regex accepts values such as2024-99-99and2024-01-01T25:00. The create handler copies these strings into the protobuf entity and sends them toCreateEntity. Parse each non-empty value as a valid date or RFC3339 timestamp, and returnhttp:BadRequestwhen parsing fails. This is separate from empty-string compatibility for omitted optional fields.🤖 Prompt for AI Agents