Files
go-caatsm/internal/adapter/parser/aviation/aviation.go
T

414 lines
12 KiB
Go
Raw Normal View History

2025-12-24 17:23:41 +08:00
package aviation
import (
"caatsm/internal/adapter/dto"
"errors"
2024-07-20 22:21:12 +08:00
"fmt"
2024-07-20 22:33:22 +08:00
"regexp"
"strings"
2024-07-20 22:25:28 +08:00
"time"
"github.com/google/uuid"
)
2024-07-22 08:12:18 +08:00
var (
otherPatterns = []*regexp.Regexp{navPattern,
remarkPattern,
selPattern,
pbnPattern,
eetPattern,
performancePattern,
regPattern,
reroutePattern}
// ErrHeaderParse indicates an invalid header section.
ErrHeaderParse = errors.New("invalid telegram header")
// ErrBodyParse indicates a failure matching the telegram body.
ErrBodyParse = errors.New("invalid telegram body")
2024-07-22 08:12:18 +08:00
)
2024-07-20 22:21:12 +08:00
type BodyParser struct {
body string
bodyPatterns map[string]BodyConfig
2024-07-20 22:21:12 +08:00
}
func NewBodyParser(body string) *BodyParser {
return &BodyParser{
bodyPatterns: bodyPatterns,
body: body,
}
2024-07-20 22:21:12 +08:00
}
// GetBodyPatterns returns the body patterns map.
// The bodyPatterns map is a reference to the package-level bodyPatterns,
// which is initialized once at startup and never modified, making it safe
// for concurrent reads without synchronization.
func (parser *BodyParser) GetBodyPatterns() map[string]BodyConfig {
return parser.bodyPatterns
2024-07-20 22:21:12 +08:00
}
func (parser *BodyParser) Parse() (string, interface{}, error) {
parser.body = strings.TrimSpace(parser.body)
category := findCategory(parser.body)
if category == "" {
2024-07-23 16:45:03 +08:00
return "", nil, fmt.Errorf("no category found in body text")
}
2024-07-23 16:45:03 +08:00
2025-12-24 17:40:52 +08:00
patternConfig, exists := parser.bodyPatterns[category]
if !exists || patternConfig.Patterns == nil {
return "", nil, fmt.Errorf("no matching pattern found for category: %s", category)
2025-12-24 17:40:52 +08:00
}
ctx := ParseContext{
Body: parser.body,
Tokens: Tokenizer{}.Tokenize(parser.body),
}
for _, p := range patternConfig.Patterns {
if data := extract(parser.body, p.Expression); data != nil {
parsed, err := parseCategory(category, ctx, data)
if err != nil {
return "", nil, err
2024-07-20 22:21:12 +08:00
}
2025-12-24 17:40:52 +08:00
return category, parsed, nil
2024-07-20 22:21:12 +08:00
}
}
2025-12-24 17:40:52 +08:00
return "", nil, fmt.Errorf("no matching pattern found for body: %s", parser.body)
}
func findCategory(body string) string {
2024-07-24 10:33:12 +08:00
if match := categoryRegex.FindStringSubmatch(body); match != nil {
for i, name := range categoryRegex.SubexpNames() {
2024-07-22 12:42:30 +08:00
if i != 0 && name == "category" {
return match[i]
}
}
}
return ""
2024-07-20 22:21:12 +08:00
}
func extract(data string, exp *regexp.Regexp) map[string]string {
// Use timeout protection to prevent ReDoS attacks
match, err := MatchWithTimeout(exp, data, DefaultRegexTimeout)
if err != nil {
// Timeout occurred - return nil to indicate no match
return nil
}
if len(match) > 0 {
return extractData(match, exp)
}
return nil
}
2024-07-20 22:33:22 +08:00
func extractData(match []string, re *regexp.Regexp) map[string]string {
data := make(map[string]string)
for i, name := range re.SubexpNames() {
if i != 0 && name != "" {
2024-07-24 15:20:00 +08:00
data[name] = strings.TrimSpace(match[i])
2024-07-20 22:33:22 +08:00
}
}
return data
}
func headerToParsedTelegram(header Header) dto.ParsedTelegram {
return dto.ParsedTelegram{
MessageID: header.MessageID,
DateTime: header.DateTime,
PriorityIndicator: header.PriorityIndicator,
PrimaryAddress: header.PrimaryAddress,
SecondaryAddresses: header.SecondaryAddresses,
Originator: header.Originator,
OriginatorDateTime: header.OriginatorDateTime,
Category: header.Category,
Body: header.Body,
Content: header.Content,
ReceivedAt: header.ReceivedAt,
ParsedAt: header.ParsedAt,
}
}
// Parse parses a raw ICAO aviation telegram and returns a ParsedTelegram with parsing status.
//
// IMPORTANT ERROR HANDLING PATTERN:
// This function intentionally returns both a non-nil ParsedTelegram AND an error when parsing fails.
// This design decision allows the caller to persist failed parse attempts with error details to the
// database for audit and compliance purposes. This pattern is specific to the aviation parser's
// error handling strategy where parser failures are permanent (ACK'd, not retried) and must be
// stored for regulatory compliance and troubleshooting.
//
// Error Handling Strategy:
// - Input validation failure: Returns ParsedTelegram with MessageStatusHeaderError + ErrHeaderParse
// - Header parse failure: Returns ParsedTelegram with MessageStatusHeaderError + ErrHeaderParse
// - Body parse failure: Returns ParsedTelegram with MessageStatusBodyError + ErrBodyParse
// - Success: Returns ParsedTelegram with MessageStatusParsed + nil error
//
// The returned ParsedTelegram is ALWAYS non-nil and safe to use, even when error is non-nil.
// Callers should check both the error and the ParsedTelegram.Status field to determine the outcome.
//
// Security:
// - Input size validation prevents DoS attacks (max 1800 chars per AFTN standard)
// - Regex timeout protection prevents ReDoS attacks (100ms timeout)
//
// Example usage:
//
// parsed, err := Parse(rawTelegram)
// if err != nil {
// // Parse failed, but parsed contains error details for storage
// repository.InsertRaw(parsed) // Store for audit
// return Permanent(err) // Don't retry
// }
// // Parse succeeded
// repository.Insert(parsed)
// publisher.Publish(parsed.BodyData)
func Parse(rawText string) (*dto.ParsedTelegram, error) {
// Validate input size to prevent DoS attacks
if err := ValidateInputSize(rawText); err != nil {
msg := dto.NewParsedTelegram()
msg.Content = rawText
msg.Comments = err.Error()
msg.ErrorReason = err.Error()
msg.Status = dto.MessageStatusHeaderError
return msg, fmt.Errorf("%w: %w", ErrHeaderParse, err)
}
header, err := ParseHeader(rawText)
2024-07-20 22:21:12 +08:00
if err != nil {
msg := dto.NewParsedTelegram()
msg.Content = rawText
msg.Comments = err.Error()
msg.ErrorReason = err.Error()
msg.Status = dto.MessageStatusHeaderError
return msg, fmt.Errorf("%w: %w", ErrHeaderParse, err)
2024-07-20 22:21:12 +08:00
}
bodyParser := NewBodyParser(header.Body)
category, bodyData, bodyErr := bodyParser.Parse()
header.Category = category
header.ParsedAt = time.Now()
2024-07-23 16:45:03 +08:00
if bodyErr != nil {
parsed := headerToParsedTelegram(header)
parsed.Parsed = false
parsed.Comments = bodyErr.Error()
parsed.Status = dto.MessageStatusBodyError
parsed.ErrorReason = bodyErr.Error()
return &parsed, fmt.Errorf("%w: %w", ErrBodyParse, bodyErr)
}
parsed := headerToParsedTelegram(header)
parsed.BodyData = bodyData
parsed.Parsed = true
parsed.Status = dto.MessageStatusParsed
parsed.Uuid = uuid.New().String()
return &parsed, nil
2024-07-20 22:21:12 +08:00
}
func cleanMessage(text string) string {
cleanedText := emptyLineRemove.ReplaceAllString(text, "")
cleanText := strings.ReplaceAll(cleanedText, "\n\n", "\n")
2024-07-24 10:33:12 +08:00
if match := bodyOnly.FindStringSubmatch(cleanText); len(match) > 1 {
bodyContent := match[2]
if bodyContent[len(bodyContent)-1] == '\n' {
return bodyContent[:len(bodyContent)-1]
}
2024-07-24 10:33:12 +08:00
return bodyContent
2024-07-22 08:12:18 +08:00
}
return ""
}
// ParseHeader parses only the header portion of the message and returns a lightweight struct
// with header fields and body content. It is used internally by the aviation parser.
type Header struct {
MessageID string
DateTime string
PriorityIndicator string
PrimaryAddress string
SecondaryAddresses string
Originator string
OriginatorDateTime string
Category string
Content string
Body string
ReceivedAt time.Time
ParsedAt time.Time
}
// ParseHeader parses the header portion of an ICAO telegram.
// It extracts message metadata (ID, datetime, addresses, originator) and separates
// the body content for subsequent parsing.
//
// Returns Header struct with parsed fields and the raw body content.
// On error, returns Header with Content field populated for audit purposes.
func ParseHeader(fullMessage string) (Header, error) {
cleaned := cleanMessage(fullMessage)
lines := strings.Split(cleaned, "\n")
if len(lines) < MinHeaderLines {
return Header{Content: fullMessage}, fmt.Errorf("invalid message format: expected at least %d lines, got %d", MinHeaderLines, len(lines))
2024-07-24 14:21:02 +08:00
}
_, messageID, dateTime, err := parseStartIndicator(lines[0])
2024-07-20 18:55:28 +08:00
if err != nil {
return Header{Content: fullMessage}, err
}
priorityIndicator, primaryAddress := parsePriorityAndPrimary(lines[1])
secondaryAddresses, originator, originatorDateTime, body := parseRemainingLines(lines[2:])
2024-07-20 18:55:28 +08:00
return Header{
MessageID: messageID,
DateTime: dateTime,
PriorityIndicator: priorityIndicator,
PrimaryAddress: primaryAddress,
SecondaryAddresses: secondaryAddresses,
Originator: originator,
OriginatorDateTime: originatorDateTime,
Content: fullMessage,
Body: body,
2024-07-20 22:25:28 +08:00
ReceivedAt: time.Now(),
2024-07-20 18:55:28 +08:00
}, nil
}
func parseStartIndicator(line string) (string, string, string, error) {
2024-07-20 22:33:22 +08:00
parts := strings.Fields(line)
if len(parts) >= MinStartIndicatorParts && strings.HasPrefix(parts[0], StartIndicatorPrefix) {
2024-07-20 22:33:22 +08:00
return parts[0], parts[1], parts[2], nil
}
2024-07-20 22:33:22 +08:00
return "", "", "", fmt.Errorf("invalid start indicator line format: %s", line)
2024-07-20 18:55:28 +08:00
}
func parsePriorityAndPrimary(line string) (string, string) {
2024-07-20 18:55:28 +08:00
parts := strings.Fields(line)
if len(parts) >= MinPriorityLineParts {
return parts[0], parts[1]
2024-07-20 18:55:28 +08:00
}
return "", ""
2024-07-20 18:55:28 +08:00
}
// isOriginatorLine checks if a dot-prefixed line matches the originator format (.CODE DATETIME).
// Returns the originator code, datetime, and whether it's a valid match.
func isOriginatorLine(line string) (originator, dateTime string, isMatch bool) {
if !strings.HasPrefix(line, ".") {
return "", "", false
}
parts := strings.Fields(line[1:])
if len(parts) < MinOriginatorParts {
return "", "", false
}
if isAllUppercaseLetters(parts[0]) && isAllDigits(parts[1]) {
return parts[0], parts[1], true
}
return "", "", false
}
// isBodyStartLine checks if a line indicates the start of the message body.
func isBodyStartLine(line string) bool {
return strings.HasPrefix(line, BeginPartMarker) || strings.HasPrefix(line, "(")
}
func parseRemainingLines(lines []string) (string, string, string, string) {
2024-07-20 18:55:28 +08:00
var (
secondaryAddresses strings.Builder
2024-07-20 18:55:28 +08:00
originator string
originatorDateTime string
body strings.Builder
inBody bool
2024-07-20 18:55:28 +08:00
)
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip empty lines and single dots
if line == "" || line == EndHeaderMarker {
continue
2024-07-20 18:55:28 +08:00
}
// Once in body, collect all remaining lines
if inBody {
body.WriteString(line + "\n")
continue
}
// Check for originator line (.CODE DATETIME)
if orig, dt, isOrig := isOriginatorLine(line); isOrig {
originator = orig
originatorDateTime = dt
inBody = true
continue
}
// Check for body start markers
if isBodyStartLine(line) {
inBody = true
// Skip lines containing NNNN (end marker)
if !strings.Contains(line, "NNNN") {
body.WriteString(line + "\n")
}
continue
}
// Try to parse as originator using regex (fallback)
if dt, orig := getOriginator(line); orig != "" {
originatorDateTime = dt
originator = orig
continue
}
// Otherwise, treat as secondary address
secondaryAddresses.WriteString(" " + line)
2024-07-20 18:55:28 +08:00
}
return secondaryAddresses.String(), originator, originatorDateTime, body.String()
}
2024-07-23 16:45:03 +08:00
func getOriginator(line string) (string, string) {
match := originator.FindStringSubmatch(line)
if len(match) >= MinOriginatorMatchGroups {
2024-07-23 16:45:03 +08:00
return match[1], match[2]
}
return "", ""
}
2024-07-23 19:12:08 +08:00
// isAllUppercaseLetters checks if a string contains only uppercase letters
func isAllUppercaseLetters(s string) bool {
if len(s) == 0 {
return false
}
for _, r := range s {
if r < 'A' || r > 'Z' {
return false
}
}
return true
}
// isAllDigits checks if a string contains only digits
func isAllDigits(s string) bool {
if len(s) == 0 {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
2024-07-23 19:12:08 +08:00
func parseOther(text string) map[string]string {
data := make(map[string]string)
for _, re := range otherPatterns {
2024-07-24 10:33:12 +08:00
if match := re.FindStringSubmatch(text); len(match) > 0 {
2024-07-23 19:12:08 +08:00
for i, name := range re.SubexpNames() {
if i != 0 && name != "" {
2024-07-23 19:36:11 +08:00
data[name] = strings.TrimSpace(match[i])
2024-07-23 19:12:08 +08:00
}
}
}
}
2024-07-24 10:33:12 +08:00
return data
2024-07-23 19:12:08 +08:00
}