diff --git a/metarParser-parsers/src/main/java/io/github/mivek/exception/ParseErrorType.java b/metarParser-parsers/src/main/java/io/github/mivek/exception/ParseErrorType.java new file mode 100644 index 00000000..a8fd789b --- /dev/null +++ b/metarParser-parsers/src/main/java/io/github/mivek/exception/ParseErrorType.java @@ -0,0 +1,19 @@ +package io.github.mivek.exception; + +/** + * @author mivek + */ +public enum ParseErrorType { + /** Station parse error. */ + STATION, + /** Delivery time parse error. */ + DELIVERY_TIME, + /** Validity parse error. */ + VALIDITY, + /** Temperature parse error. */ + TEMPERATURE, + /** Trend header parse error. */ + TREND, + /** Generic / unknown parse error. */ + INVALID_MESSAGE +} diff --git a/metarParser-parsers/src/main/java/io/github/mivek/exception/ParseException.java b/metarParser-parsers/src/main/java/io/github/mivek/exception/ParseException.java index e695976a..6e1d3065 100644 --- a/metarParser-parsers/src/main/java/io/github/mivek/exception/ParseException.java +++ b/metarParser-parsers/src/main/java/io/github/mivek/exception/ParseException.java @@ -11,6 +11,12 @@ public class ParseException extends Exception { private static final long serialVersionUID = -9022781702124062628L; /** The error code. */ private final ErrorCodes codes; + /** The error type, may be null. */ + private final ParseErrorType type; + /** The offending token, may be null. */ + private final String offendingToken; + /** The token index in the tokenized input. */ + private final int position; /** * Constructor with error code. @@ -18,8 +24,23 @@ public class ParseException extends Exception { * @param code the error code. */ public ParseException(final ErrorCodes code) { - super(code.toString()); + this(code, null, null, -1); + } + + /** + * Constructor with error code and structured metadata. + * + * @param code the error code. + * @param type the parse error type. + * @param offendingToken the token that caused the failure. + * @param position the token index in the tokenized input. + */ + public ParseException(final ErrorCodes code, final ParseErrorType type, final String offendingToken, final int position) { + super(offendingToken == null ? code.toString() : code.toString() + " [" + offendingToken + "]"); this.codes = code; + this.type = type; + this.offendingToken = offendingToken; + this.position = position; } /** @@ -28,4 +49,25 @@ public ParseException(final ErrorCodes code) { public ErrorCodes getErrorCode() { return codes; } + + /** + * @return the error type, or null if not set. + */ + public ParseErrorType getType() { + return type; + } + + /** + * @return the offending token, or null if not set. + */ + public String getOffendingToken() { + return offendingToken; + } + + /** + * @return the token index in the tokenized input. + */ + public int getPosition() { + return position; + } } diff --git a/metarParser-parsers/src/main/java/io/github/mivek/parser/AbstractWeatherCodeParser.java b/metarParser-parsers/src/main/java/io/github/mivek/parser/AbstractWeatherCodeParser.java index 4ee1231c..4053a5d7 100644 --- a/metarParser-parsers/src/main/java/io/github/mivek/parser/AbstractWeatherCodeParser.java +++ b/metarParser-parsers/src/main/java/io/github/mivek/parser/AbstractWeatherCodeParser.java @@ -3,6 +3,9 @@ import io.github.mivek.command.AirportSupplier; import io.github.mivek.command.common.CommonCommandSupplier; import io.github.mivek.enums.Flag; +import io.github.mivek.exception.ErrorCodes; +import io.github.mivek.exception.ParseErrorType; +import io.github.mivek.exception.ParseException; import io.github.mivek.model.AbstractWeatherCode; import java.time.LocalTime; import java.util.Objects; @@ -46,21 +49,27 @@ public abstract class AbstractWeatherCodeParser e * * @param weatherCode The weather code. * @param time the string to parse. + * @param position the token index in the tokenized input. * @return true if the token was a real delivery time, false if it was a validity time. + * @throws ParseException if the time token is malformed. */ - boolean parseDeliveryTime(final AbstractWeatherCode weatherCode, final String time) { - weatherCode.setDay(Integer.parseInt(time.substring(0, 2))); - int hours = Integer.parseInt(time.substring(2, 4)); - LocalTime t; - if (time.length() > 6 && time.contains("/")) { - t = LocalTime.of(hours, 0); - weatherCode.setTime(t); - return false; - } else { - int minutes = Integer.parseInt(time.substring(4, 6)); - t = LocalTime.of(hours, minutes); - weatherCode.setTime(t); - return true; + boolean parseDeliveryTime(final AbstractWeatherCode weatherCode, final String time, final int position) throws ParseException { + try { + weatherCode.setDay(Integer.parseInt(time.substring(0, 2))); + int hours = Integer.parseInt(time.substring(2, 4)); + LocalTime t; + if (time.length() > 6 && time.contains("/")) { + t = LocalTime.of(hours, 0); + weatherCode.setTime(t); + return false; + } else { + int minutes = Integer.parseInt(time.substring(4, 6)); + t = LocalTime.of(hours, minutes); + weatherCode.setTime(t); + return true; + } + } catch (NumberFormatException | StringIndexOutOfBoundsException e) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_MESSAGE, ParseErrorType.DELIVERY_TIME, time, position); } } diff --git a/metarParser-parsers/src/main/java/io/github/mivek/parser/MetarParser.java b/metarParser-parsers/src/main/java/io/github/mivek/parser/MetarParser.java index 70c3b287..d055ecdb 100644 --- a/metarParser-parsers/src/main/java/io/github/mivek/parser/MetarParser.java +++ b/metarParser-parsers/src/main/java/io/github/mivek/parser/MetarParser.java @@ -5,14 +5,17 @@ import io.github.mivek.command.metar.Command; import io.github.mivek.command.metar.MetarCommandSupplier; import io.github.mivek.enums.WeatherChangeType; +import io.github.mivek.exception.ErrorCodes; +import io.github.mivek.exception.ParseErrorType; import io.github.mivek.exception.ParseException; import io.github.mivek.factory.FactoryProvider; -import io.github.mivek.model.Airport; import io.github.mivek.model.Metar; import io.github.mivek.model.trend.MetarTrend; import io.github.mivek.model.trend.validity.AbstractMetarTrendTime; import io.github.mivek.utils.Converter; +import io.github.mivek.utils.Regex; import java.util.Objects; +import java.util.regex.Pattern; /** * This controller contains methods that parse the metar code. This class is a @@ -21,6 +24,8 @@ * @author mivek */ public final class MetarParser extends AbstractWeatherCodeParser { + /** Station ICAO pattern. */ + private static final Pattern STATION_PATTERN = Pattern.compile("^[A-Z]{4}$"); /** The command supplier. */ private final MetarCommandSupplier supplier; @@ -56,9 +61,38 @@ public MetarParser(final CommonCommandSupplier commonCommandSupplier, final Rema public Metar parse(final String code) throws ParseException { Metar m = new Metar(); String[] metarTab = tokenize(code); - int startIndex = 0; + int index = parseIdentification(m, metarTab, code); + while (index < metarTab.length) { + if (!generalParse(m, metarTab[index]) && !parseFlags(m, metarTab[index])) { + if ("NOSIG".equals(metarTab[index])) { + m.setNosig(true); + } else if (RMK.equals(metarTab[index])) { + parseRMK(m, metarTab, index); + break; + } else if (metarTab[index].equals(TEMPO) || metarTab[index].equals(BECMG)) { + MetarTrend trend = new MetarTrend(WeatherChangeType.valueOf(metarTab[index])); + index = parseTrend(index, trend, metarTab); + m.addTrend(trend); + } else { + executeCommand(m, metarTab[index]); + } + } + index++; + } + return m; + } - // Check for METAR/SPECI prefix + /** + * Parses the report-type prefix, station and delivery time. + * + * @param m the metar being built. + * @param metarTab the tokenized message. + * @param code the raw message. + * @return the index of the first body token. + * @throws ParseException if the station or delivery time is missing or malformed. + */ + private int parseIdentification(final Metar m, final String[] metarTab, final String code) throws ParseException { + int startIndex = 0; if (metarTab.length > 0) { if ("METAR".equals(metarTab[0])) { m.setReportType(io.github.mivek.enums.ReportType.METAR); @@ -68,32 +102,21 @@ public Metar parse(final String code) throws ParseException { startIndex = 1; } } - - Airport airport = getAirportSupplier().get(metarTab[startIndex]); - m.setStation(metarTab[startIndex]); - m.setAirport(airport); + if (startIndex >= metarTab.length) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_MESSAGE, ParseErrorType.STATION, null, startIndex); + } + String stationToken = metarTab[startIndex]; + if (!Regex.match(STATION_PATTERN, stationToken)) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_MESSAGE, ParseErrorType.STATION, stationToken, startIndex); + } + m.setStation(stationToken); + m.setAirport(getAirportSupplier().get(stationToken)); m.setMessage(code); - parseDeliveryTime(m, metarTab[startIndex + 1]); - int metarTabLength = metarTab.length; - int i = startIndex + 2; - while (i < metarTabLength) { - if (!generalParse(m, metarTab[i]) && !parseFlags(m, metarTab[i])) { - if ("NOSIG".equals(metarTab[i])) { - m.setNosig(true); - } else if (RMK.equals(metarTab[i])) { - parseRMK(m, metarTab, i); - break; - } else if (metarTab[i].equals(TEMPO) || metarTab[i].equals(BECMG)) { - MetarTrend trend = new MetarTrend(WeatherChangeType.valueOf(metarTab[i])); - i = parseTrend(i, trend, metarTab); - m.addTrend(trend); - } else { - executeCommand(m, metarTab[i]); - } - } - i++; + if (startIndex + 1 >= metarTab.length) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_MESSAGE, ParseErrorType.DELIVERY_TIME, null, startIndex + 1); } - return m; + parseDeliveryTime(m, metarTab[startIndex + 1], startIndex + 1); + return startIndex + 2; } /** diff --git a/metarParser-parsers/src/main/java/io/github/mivek/parser/TAFParser.java b/metarParser-parsers/src/main/java/io/github/mivek/parser/TAFParser.java index 8c6ca805..c6deeaad 100644 --- a/metarParser-parsers/src/main/java/io/github/mivek/parser/TAFParser.java +++ b/metarParser-parsers/src/main/java/io/github/mivek/parser/TAFParser.java @@ -5,6 +5,7 @@ import io.github.mivek.command.taf.Command; import io.github.mivek.command.taf.TAFCommandSupplier; import io.github.mivek.exception.ErrorCodes; +import io.github.mivek.exception.ParseErrorType; import io.github.mivek.exception.ParseException; import io.github.mivek.factory.FactoryProvider; import io.github.mivek.model.Airport; @@ -13,8 +14,10 @@ import io.github.mivek.model.trend.AbstractTafTrend; import io.github.mivek.model.trend.validity.AbstractValidity; import io.github.mivek.utils.Converter; +import io.github.mivek.utils.Regex; import java.util.Arrays; import java.util.List; +import java.util.regex.Pattern; import java.util.stream.Stream; /** @@ -27,6 +30,8 @@ public final class TAFParser extends AbstractWeatherCodeParser { private static final String TX = "TX"; /** Temperature Minimum Constant. */ private static final String TN = "TN"; + /** Temperature regex pattern. */ + private static final Pattern TEMPERATURE_REGEX = Pattern.compile("^(TX|TN)M?\\d{2}/\\d{4}Z$"); /** TAF command supplier. */ private final TAFCommandSupplier supplier; @@ -75,11 +80,15 @@ public TAF parse(final String code) throws ParseException { taf.setAirport(airport); taf.setMessage(code); // Day and time - if(parseDeliveryTime(taf, line1parts[i])) { + if(parseDeliveryTime(taf, line1parts[i], i)) { i++; } // Validity Time - taf.setValidity(parseValidity(line1parts[i])); + String validityToken = line1parts[i]; + if (!Regex.match(VALIDITY_REGEX, validityToken)) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_MESSAGE, ParseErrorType.VALIDITY, validityToken, i); + } + taf.setValidity(parseValidity(validityToken)); // Handle rest of second line. for (int j = i + 1; j < line1parts.length; j++) { @@ -88,9 +97,9 @@ public TAF parse(final String code) throws ParseException { parseRMK(taf, line1parts, j); break; } else if (part.startsWith(TX)) { - taf.setMaxTemperature(parseTemperature(part)); + taf.setMaxTemperature(parseTemperature(part, j)); } else if (part.startsWith(TN)) { - taf.setMinTemperature(parseTemperature(part)); + taf.setMinTemperature(parseTemperature(part, j)); } else { executeCommand(taf, part); } @@ -149,8 +158,14 @@ private String[][] extractLineTokens(final String tafCode) { * @param taf the TAF object to build * @param parts the token of the line */ - private void processLines(final TAF taf, final String[] parts) throws ParseException { + void processLines(final TAF taf, final String[] parts) throws ParseException { + if (parts[0].length() < 2) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_MESSAGE, ParseErrorType.TREND, parts[0], 0); + } AbstractTAFTrendParser trendParser = FactoryProvider.getTrendParser().create(parts[0].substring(0, 2)); + if (trendParser == null) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_MESSAGE, ParseErrorType.TREND, parts[0], 0); + } AbstractTafTrend trend = trendParser.parse(parts); taf.addTrend(trend); } @@ -159,15 +174,24 @@ private void processLines(final TAF taf, final String[] parts) throws ParseExcep * Parse the temperature. * * @param tempPart the string to parse. + * @param position the token index. * @return a temperature with its date. + * @throws ParseException if the temperature token is malformed. */ - TemperatureDated parseTemperature(final String tempPart) { - TemperatureDated temperature = new TemperatureDated(); - String[] parts = tempPart.split("/"); - temperature.setTemperature(Converter.convertTemperature(parts[0].substring(2))); - temperature.setDay(Integer.parseInt(parts[1].substring(0, 2))); - temperature.setHour(Integer.parseInt(parts[1].substring(2, 4))); - return temperature; + TemperatureDated parseTemperature(final String tempPart, final int position) throws ParseException { + if (!Regex.match(TEMPERATURE_REGEX, tempPart)) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_MESSAGE, ParseErrorType.TEMPERATURE, tempPart, position); + } + try { + TemperatureDated temperature = new TemperatureDated(); + String[] parts = tempPart.split("/"); + temperature.setTemperature(Converter.convertTemperature(parts[0].substring(2))); + temperature.setDay(Integer.parseInt(parts[1].substring(0, 2))); + temperature.setHour(Integer.parseInt(parts[1].substring(2, 4))); + return temperature; + } catch (NumberFormatException | StringIndexOutOfBoundsException e) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_MESSAGE, ParseErrorType.TEMPERATURE, tempPart, position); + } } } diff --git a/metarParser-parsers/src/test/java/io/github/mivek/parser/MetarParserTest.java b/metarParser-parsers/src/test/java/io/github/mivek/parser/MetarParserTest.java index 6f8cae64..17e4f082 100644 --- a/metarParser-parsers/src/test/java/io/github/mivek/parser/MetarParserTest.java +++ b/metarParser-parsers/src/test/java/io/github/mivek/parser/MetarParserTest.java @@ -1,6 +1,8 @@ package io.github.mivek.parser; import io.github.mivek.enums.*; +import io.github.mivek.exception.ErrorCodes; +import io.github.mivek.exception.ParseErrorType; import io.github.mivek.exception.ParseException; import io.github.mivek.internationalization.Messages; import io.github.mivek.model.*; @@ -485,4 +487,24 @@ void testParseWithRecentRain() throws ParseException { assertEquals(Phenomenon.RAIN, m.getWeatherConditions().get(0).getPhenomenons().get(0)); } + @Test + void testParseInvalidStation() { + ParseException e = assertThrows(ParseException.class, () -> parser.parse("METAR 12 150500Z 17005KT 6000")); + assertEquals(ParseErrorType.STATION, e.getType()); + assertEquals("12", e.getOffendingToken()); + assertEquals(1, e.getPosition()); + } + + @Test + void testParseEmptyMessageThrowsStationError() { + ParseException e = assertThrows(ParseException.class, () -> parser.parse("METAR")); + assertEquals(ParseErrorType.STATION, e.getType()); + } + + @Test + void testParseWithoutDeliveryTimeThrowsDeliveryTimeError() { + ParseException e = assertThrows(ParseException.class, () -> parser.parse("LFPG")); + assertEquals(ParseErrorType.DELIVERY_TIME, e.getType()); + } + } diff --git a/metarParser-parsers/src/test/java/io/github/mivek/parser/TAFParserTest.java b/metarParser-parsers/src/test/java/io/github/mivek/parser/TAFParserTest.java index e1d1c69c..42ba60f9 100644 --- a/metarParser-parsers/src/test/java/io/github/mivek/parser/TAFParserTest.java +++ b/metarParser-parsers/src/test/java/io/github/mivek/parser/TAFParserTest.java @@ -2,6 +2,7 @@ import io.github.mivek.enums.*; import io.github.mivek.exception.ErrorCodes; +import io.github.mivek.exception.ParseErrorType; import io.github.mivek.exception.ParseException; import io.github.mivek.internationalization.Messages; import io.github.mivek.model.TAF; @@ -95,9 +96,9 @@ void testParseValidity() { } @Test - void testParseTemperatureMax() { + void testParseTemperatureMax() throws ParseException { String tempString = "TX15/0612Z"; - TemperatureDated res = parser.parseTemperature(tempString); + TemperatureDated res = parser.parseTemperature(tempString, 6); assertThat(res.getTemperature(), is(15)); assertThat(res.getDay(), is(6)); @@ -105,9 +106,9 @@ void testParseTemperatureMax() { } @Test - void testParseTemperatureMinus() { + void testParseTemperatureMinus() throws ParseException { String tempString = "TNM02/0612Z"; - TemperatureDated res = parser.parseTemperature(tempString); + TemperatureDated res = parser.parseTemperature(tempString, 7); assertThat(res.getTemperature(), is(-2)); assertThat(res.getDay(), is(6)); @@ -959,4 +960,51 @@ void testParseWithoutDeliveryTime() throws ParseException { assertEquals(3, taf.getValidity().getEndHour()); } + @Test + void testParseInvalidTemperature() { + ParseException e = assertThrows(ParseException.class, () -> parser.parse("TAF LFPG 150500Z 1506/1612 17005KT 6000 TX/34 2112Z")); + assertEquals(ParseErrorType.TEMPERATURE, e.getType()); + assertEquals("TX/34", e.getOffendingToken()); + } + + @Test + void testParseInvalidValidity() { + ParseException e = assertThrows(ParseException.class, () -> parser.parse("TAF LFPG 150500Z 2018l/2020 17005KT 6000")); + assertEquals(ParseErrorType.VALIDITY, e.getType()); + assertEquals("2018l/2020", e.getOffendingToken()); + } + + @Test + void testParseInvalidDeliveryTime() { + ParseException e = assertThrows(ParseException.class, () -> parser.parse("TAF LFPG 2007XXZ 1506/1612 17005KT 6000")); + assertEquals(ParseErrorType.DELIVERY_TIME, e.getType()); + assertEquals("2007XXZ", e.getOffendingToken()); + } + + @Test + void testProcessLinesWithShortToken() { + TAF taf = new TAF(); + String[] parts = {"X"}; + ParseException e = assertThrows(ParseException.class, () -> parser.processLines(taf, parts)); + assertEquals(ParseErrorType.TREND, e.getType()); + assertEquals("X", e.getOffendingToken()); + } + + @Test + void testProcessLinesWithUnknownTrendPrefix() { + TAF taf = new TAF(); + String[] parts = {"XX", "1506/1508"}; + ParseException e = assertThrows(ParseException.class, () -> parser.processLines(taf, parts)); + assertEquals(ParseErrorType.TREND, e.getType()); + assertEquals("XX", e.getOffendingToken()); + } + + @Test + void testParseInvalidTemperatureWithPosition() { + ParseException e = assertThrows(ParseException.class, () -> parser.parseTemperature("TX/34", 7)); + assertEquals(ParseErrorType.TEMPERATURE, e.getType()); + assertEquals("TX/34", e.getOffendingToken()); + assertEquals(7, e.getPosition()); + } + }