diff --git a/opengin/ingestion-api/update_api_service_copy.bal b/opengin/ingestion-api/update_api_service_copy.bal index 62006ce4..aab2f4d7 100644 --- a/opengin/ingestion-api/update_api_service_copy.bal +++ b/opengin/ingestion-api/update_api_service_copy.bal @@ -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|error rawKey = item.key; + if rawKey is error || rawKey.toString().trim().length() == 0 { + return "relationship entry key is required"; + } + RelationshipPayload|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 { + foreach var [key, val] in rels.entries() { + if val is map { + 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|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 { + foreach var [key, val] in rels.entries() { + if val is map { + 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 { + 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|error rawKey = item.key; + if rawKey is error || rawKey.toString().trim().length() == 0 { + 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 { + 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 { + body: { + "error": "Invalid request parameters", + "details": validated.message() + } + }; + } + string? relError = validateNewRelationships(validated?.relationships ?: []); + if relError is string { + return { + body: { + "error": "Invalid request parameters", + "details": relError + } + }; + } + string? attrError = validateAttributeValues(validated?.attributes ?: []); + if attrError is string { + return { + 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 { + body: { + "error": "Invalid request parameters", + "details": validated.message() + } + }; + } + string? semanticError = validateUpdatePayload(id, validated); + if semanticError is string { + return { + body: { + "error": "Invalid request parameters", + "details": semanticError + } + }; + } + string? relError = validateUpdateRelationships(validated?.relationships ?: []); + if relError is string { + return { + body: { + "error": "Invalid request parameters", + "details": relError + } + }; + } + string? attrError = validateAttributeValues(validated?.attributes ?: []); + if attrError is string { + return { + 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: { diff --git a/opengin/read-api/read_api_service.bal b/opengin/read-api/read_api_service.bal index e1875374..adac6731 100644 --- a/opengin/read-api/read_api_service.bal +++ b/opengin/read-api/read_api_service.bal @@ -10,6 +10,7 @@ import ballerina/io; import ballerina/lang.'int as langint; import ballerina/protobuf.types.'any; import ballerina/protobuf.types.'any as pbAny; +import ballerina/constraint; // BAL_CONFIG_VAR_CORESERVICEURL configurable string coreServiceUrl = "http://localhost:50051"; @@ -33,6 +34,27 @@ grpc:ClientConfiguration grpcConfig = { COREServiceClient ep = check new (coreServiceUrl, grpcConfig); +// Constrained path and query parameter types. The HTTP listener validates +// these at binding time and rejects invalid values with 400 before any gRPC +// call is made. +@constraint:String { + pattern: {value: re `.*\S.*`, message: "entityId is required"} +} +type EntityIdParam string; + +@constraint:String { + pattern: {value: re `.*\S.*`, message: "attributeName is required"} +} +type AttributeNameParam 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 DateTimeParam string; + // Helper function to extract string representation based on typeUrl function extractValueAsString('any:Any anyValue) returns string { string typeUrl = anyValue.typeUrl; @@ -137,8 +159,8 @@ service /v1 on ep0 { # # + fields - List of field names to return. Defaults to ['*'] (all fields). # + return - Attribute value(s) - resource function post entities/[string entityId]/attributes/[string attributeName](string? startTime, string? endTime, @http:Payload attributes_attributeName_body payload, string[]? fields) returns RecordStringStartStringendStringvalueRecordStringStartStringendStringvalueArrayOk|http:NotFound|error { - // Set default fields value to ["*"] if not provided or if empty array + resource function post entities/[EntityIdParam entityId]/attributes/[AttributeNameParam attributeName](DateTimeParam? startTime, DateTimeParam? endTime, @http:Payload attributes_attributeName_body payload, string[]? fields) returns RecordStringStartStringendStringvalueRecordStringStartStringendStringvalueArrayOk|http:NotFound|error { + // Defaults to empty array when fields is omitted or empty string[] fieldsToUse = (fields == () || fields.length() == 0) ? [] : fields; json recordsToUse = (payload.records ?: []).toJson(); @@ -244,7 +266,7 @@ service /v1 on ep0 { # Get metadata of an entity # # + return - Entity metadata - resource function get entities/[string entityId]/metadata() returns EntitiesEntityIdMetadataResponse|error { + resource function get entities/[EntityIdParam entityId]/metadata() returns EntitiesEntityIdMetadataResponse|error { // Create entity filter with empty fields Entity entityFilter = { id: entityId, @@ -298,7 +320,7 @@ service /v1 on ep0 { # Get related entity IDs # # + return - List of related entities - resource function post entities/[string entityId]/relations(@http:Payload entityId_relations_body payload) returns RecordStringidStringrelatedEntityIdStringnameStringstartTimeStringendTimeStringdirectionArrayOk|http:BadRequest|error { + resource function post entities/[EntityIdParam entityId]/relations(@http:Payload entityId_relations_body payload) returns RecordStringidStringrelatedEntityIdStringnameStringstartTimeStringendTimeStringdirectionArrayOk|http:BadRequest|error { // Validate that startTime/endTime and activeAt are not used together boolean hasTimeRange = (payload.startTime is string && payload.startTime != "") || (payload.endTime is string && payload.endTime != ""); boolean hasActiveAt = payload.activeAt is string && payload.activeAt != ""; diff --git a/opengin/read-api/types.bal b/opengin/read-api/types.bal index d7c059da..29295179 100644 --- a/opengin/read-api/types.bal +++ b/opengin/read-api/types.bal @@ -1,6 +1,12 @@ // AUTO-GENERATED FILE. // This file is auto-generated by the Ballerina OpenAPI tool. +// +// Field constraints below use the `ballerina/constraint` standard library so +// invalid payloads are rejected with 400 at request binding time, before any +// gRPC call is made. +// If this file is regenerated, re-apply the `@constraint` annotations. +import ballerina/constraint; import ballerina/http; public type entities_search_body record { @@ -8,7 +14,19 @@ public type entities_search_body record { string id?; entitiessearch_kind kind?; string name?; + @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 created format, expected YYYY-MM-DD or RFC3339" + } + } string created?; + @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 terminated format, expected YYYY-MM-DD or RFC3339" + } + } string terminated?; }; @@ -42,7 +60,15 @@ public type inline_response_200 record { public type attributes_attributeName_body record { # List of record filters to apply row base filtering - record {string field_name?; "eq"|"neq"|"gt"|"lt"|"gte"|"lte"|"contains"|"notcontains" operator = "eq"; string value?;}[] records?; + record { + @constraint:String { + minLength: {value: 1, message: "records[].field_name is required"}, + pattern: {value: re `.*\S.*`, message: "records[].field_name is required"} + } + string field_name; + "eq"|"neq"|"gt"|"lt"|"gte"|"lte"|"contains"|"notcontains" operator = "eq"; + string value?; + }[] records?; }; public type RecordStringStartStringendStringvalueRecordStringStartStringendStringvalueArrayOk record {| @@ -59,11 +85,32 @@ public type entityId_relations_body record { # Optional relation name filter string name?; # Filter relations active at this specific time + @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 activeAt format, expected YYYY-MM-DD or RFC3339" + } + } string activeAt?; # Filter relations with start time + @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 startTime format, expected YYYY-MM-DD or RFC3339" + } + } string startTime?; # Filter relations with end time + @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 endTime format, expected YYYY-MM-DD or RFC3339" + } + } string endTime?; # Filter by relation direction + @constraint:String { + pattern: {value: re `(OUTGOING|INCOMING)`, message: "direction must be either OUTGOING or INCOMING"} + } string direction?; };