Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,45 @@ Follow Conventional Commits: `<type>(<scope>): <subject>` — e.g., `feat(parser

### Services Are Singletons; Parsers Are Not
`MetarService.getInstance()` / `TAFService.getInstance()` return static singletons. `MetarParser` and `TAFParser` use plain constructors (the static `getInstance()` on parsers is `@Deprecated(forRemoval = true)`). Prefer constructor instantiation for parsers.

## Weather Provider Architecture

The **Strategy pattern** is used for weather data retrieval to eliminate the NOAA single point of failure and enable multiple data sources.

### WeatherProvider Interface
Located in `io.github.mivek.service.provider`, the interface defines:
- `String retrieveMetar(String icao)` — fetches raw METAR string (without "METAR"/"SPECI" prefix)
- `String retrieveTaf(String icao)` — fetches raw TAF string ready for parsing

Throws `ParseException(ErrorCodes.ERROR_CODE_INVALID_ICAO)` when a station is not found.

### Built-in Providers
- **NOAAWeatherProvider** (default): Uses `https://tgftp.nws.noaa.gov/data/`. Handles NOAA-specific TAF reformatting (merging lines when first line is only "TAF").
- **AviationWeatherProvider**: Uses `https://aviationweather.gov/api/data/metar` and `/taf`. Strips "METAR "/"SPECI " prefixes for parser compatibility.

### AbstractWeatherProvider Base Class
Provides shared HTTP utilities (all `protected final`):
- `checkIcao(String icao)` — validates 4-character ICAO codes
- `buildRequest(String url)` — creates HTTP GET request with HTTP/2
- `getHttpResponse(String url)` — executes request and throws `ParseException` on non-200 status

### Using Custom Providers
```java
// Use a specific provider for a single request
MetarService metarService = MetarService.withProvider(new MyWeatherProvider());
Metar m = metarService.retrieveFromAirport("LFPG");

TAFService tafService = TAFService.withProvider(new MyWeatherProvider());
TAF t = tafService.retrieveFromAirport("LFPG");

// Default singletons still use NOAA
MetarService.getInstance().retrieveFromAirport("LFPG");
TAFService.getInstance().retrieveFromAirport("LFPG");
```

### Adding a New Provider
1. Extend `AbstractWeatherProvider` and implement `retrieveMetar()` and `retrieveTaf()`
2. Return raw weather strings in parser-compatible format
3. Create comprehensive tests in `io.github.mivek.service.provider` with 100% branch coverage
4. Add integration tests to `MetarServiceTest` and `TAFServiceTest` using `withProvider(new YourProvider())`
5. See CONTRIBUTING.md for detailed step-by-step instructions
54 changes: 54 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,57 @@ If you are willing to add a new language, please use https://crwd.in/metarParser
Once a language is complete at 100%, the translation file will be added to the project.

Thank you

## Adding a Weather Provider

Weather providers are responsible for fetching raw METAR and TAF strings from an external source.
The built-in providers are `NOAAWeatherProvider` (default) and `AviationWeatherProvider`.

### Steps to add a new provider

1. **Implement the `WeatherProvider` interface** in `metarParser-services`:

```java
package io.github.mivek.service.provider;

public final class MyWeatherProvider extends AbstractWeatherProvider {

@Override
public String retrieveMetar(final String icao)
throws ParseException, IOException, URISyntaxException, InterruptedException {
checkIcao(icao);
// Fetch from your source and return the raw METAR string (without "METAR" prefix).
// Throw ParseException(ErrorCodes.ERROR_CODE_INVALID_ICAO) if the station is not found.
}

@Override
public String retrieveTaf(final String icao)
throws ParseException, IOException, URISyntaxException, InterruptedException {
checkIcao(icao);
// Fetch from your source and return a TAF string ready for TAFParser.
// Throw ParseException(ErrorCodes.ERROR_CODE_INVALID_ICAO) if the station is not found.
}
}
```

Key points:
- Extend `AbstractWeatherProvider` to reuse `checkIcao()`, `buildRequest()`, and `getHttpResponse()`.
- Return the raw weather code in the format expected by `MetarParser` / `TAFParser` (no `METAR` or `SPECI` prefix for METARs; follow `NOAAWeatherProvider.format()` as a reference for TAFs).
- Throw `new ParseException(ErrorCodes.ERROR_CODE_INVALID_ICAO)` when the station is not found or the ICAO is invalid.

2. **Write tests** in `metarParser-services`:

- Add a `MyWeatherProviderTest` class in `src/test/java/io/github/mivek/service/provider/` that covers all branches of your implementation (100% branch coverage is required for this module).
- Add integration tests to `MetarServiceTest` and `TAFServiceTest` that use `MetarService.withProvider(new MyWeatherProvider())` and `TAFService.withProvider(new MyWeatherProvider())` to verify that the data returned by your provider can actually be parsed.

3. **Use the provider** via the factory methods:

```java
MetarService metarService = MetarService.withProvider(new MyWeatherProvider());
Metar metar = metarService.retrieveFromAirport("LFPG");

TAFService tafService = TAFService.withProvider(new MyWeatherProvider());
TAF taf = tafService.retrieveFromAirport("LFPG");
```

The default singleton (`MetarService.getInstance()` / `TAFService.getInstance()`) continues to use the NOAA provider.
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
package io.github.mivek.service;

import io.github.mivek.exception.ErrorCodes;
import io.github.mivek.exception.ParseException;
import io.github.mivek.model.AbstractWeatherCode;
import io.github.mivek.parser.AbstractWeatherCodeParser;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.stream.Stream;
import io.github.mivek.service.provider.WeatherProvider;

/**
* Abstract service.
Expand All @@ -28,13 +18,18 @@ public abstract class AbstractWeatherCodeService<T extends AbstractWeatherCode>
/** The parser. */
private final AbstractWeatherCodeParser<T> parser;

/** The weather provider used to fetch raw weather data. */
private final WeatherProvider provider;

/**
* Protected constructor to be used by subclasses.
*
* @param parser the parser to set.
* @param parser the parser to set.
* @param provider the weather provider to use for fetching raw data.
*/
protected AbstractWeatherCodeService(final AbstractWeatherCodeParser<T> parser) {
protected AbstractWeatherCodeService(final AbstractWeatherCodeParser<T> parser, final WeatherProvider provider) {
this.parser = parser;
this.provider = provider;
}

/**
Expand All @@ -45,52 +40,9 @@ protected AbstractWeatherCodeParser<T> getParser() {
}

/**
* Checks if the icao is composed of 4 characteres.
* @param icao The icao to test
* @throws ParseException if the icao is invalid
*/
void checkIcao(final String icao) throws ParseException {
if (icao.length() != AbstractWeatherCodeService.ICAO) {
throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_ICAO);
}
}

/**
* Builds a request object.
* @param website The URI of the request
* @return The request object ready to use.
* @throws URISyntaxException When URI is invalid
* @return the weather provider.
*/
HttpRequest buildRequest(final String website) throws URISyntaxException {
return HttpRequest.newBuilder()
.uri(new URI(website))
.GET()
.version(HttpClient.Version.HTTP_2)
.build();
}

/**
* Builds the request and return the HTTP response.
* @param icao The ICAO code of the station.
* @param noaaUrl The URL of the NOAA
* @return the HTTP response
* @throws ParseException When the icao is invalid
* @throws URISyntaxException When the URI is invalid
* @throws IOException When network issue
* @throws InterruptedException When network issue
*/
protected HttpResponse<Stream<String>> getResponse(final String icao, final String noaaUrl) throws ParseException, IOException, InterruptedException, URISyntaxException {
checkIcao(icao);
String website = noaaUrl + icao.toUpperCase()
+ ".TXT";
HttpRequest request = buildRequest(website);

HttpResponse<Stream<String>> response = HttpClient.newBuilder()
.build()
.send(request, HttpResponse.BodyHandlers.ofLines());
if (response.statusCode() != HttpURLConnection.HTTP_OK) {
throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_ICAO);
}
return response;
protected WeatherProvider getProvider() {
return provider;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,45 @@
import io.github.mivek.exception.ParseException;
import io.github.mivek.model.Metar;
import io.github.mivek.parser.MetarParser;
import io.github.mivek.service.provider.NOAAWeatherProvider;
import io.github.mivek.service.provider.WeatherProvider;

import java.io.IOException;
import java.net.URISyntaxException;
import java.net.http.HttpResponse;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
* Class representing the service for metar.
*
* @author mivek
*/
public final class MetarService extends AbstractWeatherCodeService<Metar> {
/** URL to retrieve the metar from. */
private static final String NOAA_METAR_URL = "https://tgftp.nws.noaa.gov/data/observations/metar/stations/";
/** Instance. */
private static final MetarService INSTANCE = new MetarService();

/**
* Private constructor.
* Private default constructor. Uses the NOAA provider.
*/
private MetarService() {
super(new MetarParser());
this(new NOAAWeatherProvider());
}

/**
* Private constructor for a specific provider.
*
* @param provider the weather provider to use.
*/
private MetarService(final WeatherProvider provider) {
super(new MetarParser(), provider);
}

/**
* Creates a new {@link MetarService} instance configured with the given provider.
*
* @param provider the weather provider to use for fetching METAR data.
* @return a new {@link MetarService} using the specified provider.
*/
public static MetarService withProvider(final WeatherProvider provider) {
return new MetarService(provider);
}

@Override
Expand All @@ -35,8 +51,7 @@ public Metar decode(final String code) throws ParseException {

@Override
public Metar retrieveFromAirport(final String icao) throws ParseException, IOException, URISyntaxException, InterruptedException {
HttpResponse<Stream<String>> response = getResponse(icao, NOAA_METAR_URL);
return getParser().parse(response.body().skip(1).collect(Collectors.joining()));
return getParser().parse(getProvider().retrieveMetar(icao));
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,47 @@
package io.github.mivek.service;

import io.github.mivek.exception.ErrorCodes;
import io.github.mivek.exception.ParseException;
import io.github.mivek.model.TAF;
import io.github.mivek.parser.TAFParser;
import io.github.mivek.service.provider.NOAAWeatherProvider;
import io.github.mivek.service.provider.WeatherProvider;

import java.io.IOException;
import java.net.URISyntaxException;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

/**
* Facade for TAF.
*
* @author mivek
*/
public final class TAFService extends AbstractWeatherCodeService<TAF> {
/** URL to retrieve the TAF from. */
private static final String NOAA_TAF_URL = "https://tgftp.nws.noaa.gov/data/forecasts/taf/stations/";
/** The instance of the service. */
private static final TAFService INSTANCE = new TAFService();

/**
* Constructor.
* Private default constructor. Uses the NOAA provider.
*/
private TAFService() {
super(new TAFParser());
this(new NOAAWeatherProvider());
}

/**
* Private constructor for a specific provider.
*
* @param provider the weather provider to use.
*/
private TAFService(final WeatherProvider provider) {
super(new TAFParser(), provider);
}

/**
* Creates a new {@link TAFService} instance configured with the given provider.
*
* @param provider the weather provider to use for fetching TAF data.
* @return a new {@link TAFService} using the specified provider.
*/
public static TAFService withProvider(final WeatherProvider provider) {
return new TAFService(provider);
}

@Override
Expand All @@ -38,32 +51,7 @@ public TAF decode(final String code) throws ParseException {

@Override
public TAF retrieveFromAirport(final String icao) throws IOException, ParseException, URISyntaxException, InterruptedException {
HttpResponse<Stream<String>> response = getResponse(icao, NOAA_TAF_URL);
StringBuilder sb = new StringBuilder();
// Throw the first line since it is not part of the TAF event.
response.body().skip(1).forEach(currentLine -> sb.append(currentLine.replaceAll("\\s{2,}", "")).append("\n"));
return getParser().parse(format(sb.toString()));
}

/**
* Reformat the first line of the code.
*
* @param code the first line of the TAF event.
* @return the formated taf code.
* @throws ParseException when an error occurs.
*/
String format(final String code) throws ParseException {
String[] lines = code.split("\n");
if (!TAFParser.TAF.equals(lines[0].trim())) {
return code;
}
if ("AMD TAF".equals(lines[1].trim())) {
List<String> list = new ArrayList<>(Arrays.asList(lines));
list.remove(1);
lines = list.toArray(new String[0]);
}
// Case of TAF AMD, the 2 first lines must be merged.
return Arrays.stream(lines).reduce((x, y) -> x + y + "\n").orElseThrow(() -> new ParseException(ErrorCodes.ERROR_CODE_INVALID_MESSAGE));
return getParser().parse(getProvider().retrieveTaf(icao));
}

/**
Expand Down
Loading
Loading