✨ Add configuration for Code Review Automation and enhance .gitignore. Introduce .coderabbit.yml for automated reviews with profiles for correctness, maintainability, security, and performance. Update paths to include relevant directories and exclude generated files. Modify .gitignore to include coverage reports and generated files. Refactor Docker Compose to use updated paths for database initialization scripts. Update Go module dependencies and enhance Makefile with new code generation tasks. Transition domain models to a new DTO structure for better separation of concerns.
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"caatsm/internal/domain"
|
||||
"caatsm/internal/adapter/dto"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
SSR = "ssr"
|
||||
DepartureCode = "dep"
|
||||
DepartureTime = "dep_time"
|
||||
ArrivalCode = "arr"
|
||||
ArrivalTime = "arr_time"
|
||||
DestinationCode = "dest"
|
||||
OtherInfo = "other"
|
||||
|
||||
ReferenceData = "reference_data"
|
||||
Aircraft = "aircraft"
|
||||
CategorySurveillance = "surve"
|
||||
Indicator = "indicator"
|
||||
Other = "other"
|
||||
AircraftID = "aircraft"
|
||||
Surveillance = "surve"
|
||||
Speed = "speed"
|
||||
Level = "level"
|
||||
Route = "route"
|
||||
EstimatedTime = "estt"
|
||||
AlternateAirport = "alter"
|
||||
PBN = "pbn"
|
||||
NavigationEquipment = "nav"
|
||||
EstimatedElapsedTime = "eet"
|
||||
SELCALCode = "sel"
|
||||
PerformanceCategory = "per"
|
||||
RerouteInformation = "rif"
|
||||
Remarks = "remark"
|
||||
)
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
type BodyParser struct {
|
||||
body string
|
||||
bodyPatterns map[string]BodyConfig
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewBodyParser(body string) *BodyParser {
|
||||
return &BodyParser{
|
||||
bodyPatterns: bodyPatterns,
|
||||
body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func (parser *BodyParser) GetBodyPatterns() map[string]BodyConfig {
|
||||
parser.mu.Lock()
|
||||
defer parser.mu.Unlock()
|
||||
return parser.bodyPatterns
|
||||
}
|
||||
|
||||
func (parser *BodyParser) SetBodyPatterns(patterns map[string]BodyConfig) {
|
||||
parser.mu.Lock()
|
||||
defer parser.mu.Unlock()
|
||||
parser.bodyPatterns = patterns
|
||||
}
|
||||
|
||||
func (parser *BodyParser) Parse() (string, interface{}, error) {
|
||||
parser.mu.Lock()
|
||||
defer parser.mu.Unlock()
|
||||
|
||||
parser.body = strings.TrimSpace(parser.body)
|
||||
category := findCategory(parser.body)
|
||||
if category == "" {
|
||||
return "", nil, fmt.Errorf("no category found in body text")
|
||||
}
|
||||
|
||||
if patternConfig, exists := parser.bodyPatterns[category]; exists && patternConfig.Patterns != nil {
|
||||
for _, p := range patternConfig.Patterns {
|
||||
if data := extract(parser.body, p.Expression); data != nil {
|
||||
return parser.createBodyData(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil, fmt.Errorf("no matching pattern found for body: %s", parser.body)
|
||||
}
|
||||
|
||||
func findCategory(body string) string {
|
||||
if match := categoryRegex.FindStringSubmatch(body); match != nil {
|
||||
for i, name := range categoryRegex.SubexpNames() {
|
||||
if i != 0 && name == "category" {
|
||||
return match[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extract(data string, exp *regexp.Regexp) map[string]string {
|
||||
match := exp.FindStringSubmatch(data)
|
||||
if len(match) > 0 {
|
||||
return extractData(match, exp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
data[name] = strings.TrimSpace(match[i])
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (parser *BodyParser) createBodyData(data map[string]string) (string, interface{}, error) {
|
||||
switch category := data["category"]; category {
|
||||
case CategoryArrival:
|
||||
return category, &domain.ARR{
|
||||
Category: data[Category],
|
||||
AircraftID: data[FlightNumber],
|
||||
SSRModeAndCode: data[SSR],
|
||||
DepartureAirport: data[DepartureCode],
|
||||
ArrivalAirport: data[ArrivalCode],
|
||||
ArrivalTime: data[ArrivalTime],
|
||||
}, nil
|
||||
case CategoryDeparture:
|
||||
return category, &domain.DEP{
|
||||
Category: data[Category],
|
||||
AircraftID: data[FlightNumber],
|
||||
SSRModeAndCode: data[SSR],
|
||||
DepartureAirport: data[DepartureCode],
|
||||
DepartureTime: data[DepartureTime],
|
||||
Destination: data[ArrivalCode],
|
||||
}, nil
|
||||
case CategoryCancellation:
|
||||
return category, &domain.CNL{
|
||||
Category: data[category],
|
||||
AircraftID: data[FlightNumber],
|
||||
DepartureAirport: data[DepartureCode],
|
||||
DestinationAirport: data[ArrivalCode],
|
||||
}, nil
|
||||
case CategoryDelay:
|
||||
return category, &domain.DLA{
|
||||
Category: data[Category],
|
||||
AircraftID: data[FlightNumber],
|
||||
DepartureAirport: data[DepartureCode],
|
||||
NewDepartureTime: data[DepartureTime],
|
||||
ArrivalAirport: data[ArrivalCode],
|
||||
ArrivalTime: data[ArrivalTime],
|
||||
}, nil
|
||||
case CategoryFlightPlan:
|
||||
otherData := parseOther(data[OtherInfo])
|
||||
return category, &domain.FPL{
|
||||
Category: data[Category],
|
||||
FlightNumber: data[FlightNumber],
|
||||
ReferenceData: data[ReferenceData],
|
||||
AircraftID: data[AircraftID],
|
||||
SSRModeAndCode: data[Surveillance],
|
||||
FlightRulesAndType: data[Indicator],
|
||||
CruisingSpeedAndLevel: data[Speed] + data[Level],
|
||||
DepartureAirport: data[DepartureCode],
|
||||
DepartureTime: data[DepartureTime],
|
||||
Route: data[Route],
|
||||
DestinationAndTotalTime: data[DestinationCode] + data[EstimatedTime],
|
||||
AlternateAirport: data[AlternateAirport],
|
||||
OtherInfo: data[OtherInfo],
|
||||
Register: otherData[Register],
|
||||
EstimatedArrivalTime: data[EstimatedTime],
|
||||
PBN: otherData[PBN],
|
||||
NavigationEquipment: otherData[NavigationEquipment],
|
||||
EstimatedElapsedTime: otherData[EstimatedElapsedTime],
|
||||
SELCALCode: otherData[SELCALCode],
|
||||
PerformanceCategory: otherData[PerformanceCategory],
|
||||
RerouteInformation: otherData[RerouteInformation],
|
||||
Remarks: otherData[Remarks],
|
||||
}, nil
|
||||
default:
|
||||
return category, nil, fmt.Errorf("invalid message type: %s", category)
|
||||
}
|
||||
}
|
||||
|
||||
func Parse(rawText string) (*dto.ParsedTelegram, error) {
|
||||
header, err := ParseHeader(rawText)
|
||||
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)
|
||||
}
|
||||
|
||||
bodyParser := NewBodyParser(header.Body)
|
||||
category, bodyData, bodyErr := bodyParser.Parse()
|
||||
header.Category = category
|
||||
header.ParsedAt = time.Now()
|
||||
|
||||
if bodyErr != nil {
|
||||
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,
|
||||
Parsed: false,
|
||||
Comments: bodyErr.Error(),
|
||||
Status: dto.MessageStatusBodyError,
|
||||
ErrorReason: bodyErr.Error(),
|
||||
}, fmt.Errorf("%w: %w", ErrBodyParse, bodyErr)
|
||||
}
|
||||
|
||||
parsed := &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,
|
||||
BodyData: bodyData,
|
||||
ReceivedAt: header.ReceivedAt,
|
||||
ParsedAt: header.ParsedAt,
|
||||
Parsed: true,
|
||||
Status: dto.MessageStatusParsed,
|
||||
ErrorReason: "",
|
||||
}
|
||||
|
||||
parsed.Uuid = uuid.New().String()
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func cleanMessage(text string) string {
|
||||
cleanedText := emptyLineRemove.ReplaceAllString(text, "")
|
||||
cleanText := strings.ReplaceAll(cleanedText, "\n\n", "\n")
|
||||
if match := bodyOnly.FindStringSubmatch(cleanText); len(match) > 1 {
|
||||
bodyContent := match[2]
|
||||
if bodyContent[len(bodyContent)-1] == '\n' {
|
||||
return bodyContent[:len(bodyContent)-1]
|
||||
}
|
||||
return bodyContent
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func ParseHeader(fullMessage string) (Header, error) {
|
||||
log := zap.S()
|
||||
cleaned := cleanMessage(fullMessage)
|
||||
lines := strings.Split(cleaned, "\n")
|
||||
|
||||
if len(lines) < 3 {
|
||||
log.Warnf("invalid message format: %s", fullMessage)
|
||||
return Header{Content: fullMessage}, fmt.Errorf("invalid message format: %s", fullMessage)
|
||||
}
|
||||
|
||||
_, messageID, dateTime, err := parseStartIndicator(lines[0])
|
||||
if err != nil {
|
||||
return Header{Content: fullMessage}, err
|
||||
}
|
||||
|
||||
priorityIndicator, primaryAddress := parsePriorityAndPrimary(lines[1])
|
||||
secondaryAddresses, originator, originatorDateTime, body := parseRemainingLines(lines[2:])
|
||||
|
||||
return Header{
|
||||
MessageID: messageID,
|
||||
DateTime: dateTime,
|
||||
PriorityIndicator: priorityIndicator,
|
||||
PrimaryAddress: primaryAddress,
|
||||
SecondaryAddresses: secondaryAddresses,
|
||||
Originator: originator,
|
||||
OriginatorDateTime: originatorDateTime,
|
||||
Content: fullMessage,
|
||||
Body: body,
|
||||
ReceivedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseStartIndicator(line string) (string, string, string, error) {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 3 && strings.HasPrefix(parts[0], StartIndicatorPrefix) {
|
||||
return parts[0], parts[1], parts[2], nil
|
||||
}
|
||||
zap.S().Warnf("invalid start indicator line format: %s", line)
|
||||
return "", "", "", fmt.Errorf("invalid start indicator line format: %s", line)
|
||||
}
|
||||
|
||||
func parsePriorityAndPrimary(line string) (string, string) {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
zap.S().Warnf("invalid priority and primary address line format: %s", line)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func parseRemainingLines(lines []string) (string, string, string, string) {
|
||||
var (
|
||||
secondaryAddresses string
|
||||
originator string
|
||||
originatorDateTime string
|
||||
bodyAndFooter strings.Builder
|
||||
headerEnded bool
|
||||
)
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if headerEnded {
|
||||
bodyAndFooter.WriteString(line + "\n")
|
||||
} else {
|
||||
switch {
|
||||
case line == EndHeaderMarker:
|
||||
case strings.HasPrefix(line, "."):
|
||||
originatorInfo := strings.Fields(line[1:])
|
||||
if len(originatorInfo) >= 2 {
|
||||
originator = originatorInfo[0]
|
||||
originatorDateTime = originatorInfo[1]
|
||||
}
|
||||
headerEnded = true
|
||||
case strings.HasPrefix(line, BeginPartMarker) || strings.HasPrefix(line, "("):
|
||||
headerEnded = true
|
||||
if strings.Index(line, "NNNN") > 0 {
|
||||
break
|
||||
}
|
||||
bodyAndFooter.WriteString(line + "\n")
|
||||
default:
|
||||
if o1, o2 := getOriginator(line); o1 != "" {
|
||||
originatorDateTime = o1
|
||||
originator = o2
|
||||
} else {
|
||||
secondaryAddresses = secondaryAddresses + " " + line
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return secondaryAddresses, originator, originatorDateTime, bodyAndFooter.String()
|
||||
}
|
||||
|
||||
func getOriginator(line string) (string, string) {
|
||||
match := originator.FindStringSubmatch(line)
|
||||
if len(match) >= 3 {
|
||||
return match[1], match[2]
|
||||
}
|
||||
zap.S().Warnf("invalid originator line format: %s", line)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func parseOther(text string) map[string]string {
|
||||
data := make(map[string]string)
|
||||
for _, re := range otherPatterns {
|
||||
if match := re.FindStringSubmatch(text); len(match) > 0 {
|
||||
for i, name := range re.SubexpNames() {
|
||||
if i != 0 && name != "" {
|
||||
data[name] = strings.TrimSpace(match[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
Reference in New Issue
Block a user