From 4c5f7d0535d7ff18b05d8a584ee9932ceeca5e7b Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 20:13:15 -0300 Subject: [PATCH 01/13] feat: add a Go SDK alongside the Python and Node.js ports The port carries the same features as the other two, with the surface shaped for Go: a context on every request, errors instead of exceptions, Options instead of keyword arguments, and a zero value that means the default the other ports declare in their signatures. Two pieces have no counterpart in the sibling ports, because their HTTP clients provide them: - a cookie jar that honours the scope FR24 sets, so a cookie stored by www. is not replayed to cdn./api./data-live., and a single cookie can be dropped to shed load-balancer stickiness without losing the login - content decoding owned by the package (gzip, deflate, brotli), which is what makes the size budget enforceable before a body expands Parity is enforced rather than promised: ports_test.go reads the Python source and fails when the country list, the zones, the tracker config, the flight attributes or the client methods drift apart. --- .gitignore | 3 + docs/go.md | 251 ++++ go/LICENSE | 21 + go/Makefile | 142 +++ go/README.md | 277 +++++ go/flightradarapi/airport.go | 226 ++++ go/flightradarapi/api.go | 849 ++++++++++++++ go/flightradarapi/api_test.go | 1331 ++++++++++++++++++++++ go/flightradarapi/cookies.go | 352 ++++++ go/flightradarapi/cookies_test.go | 252 ++++ go/flightradarapi/core.go | 158 +++ go/flightradarapi/countries.go | 484 ++++++++ go/flightradarapi/doc.go | 39 + go/flightradarapi/entities_test.go | 571 ++++++++++ go/flightradarapi/entity.go | 69 ++ go/flightradarapi/errors.go | 82 ++ go/flightradarapi/example_test.go | 183 +++ go/flightradarapi/flight.go | 495 ++++++++ go/flightradarapi/flighttrackerconfig.go | 104 ++ go/flightradarapi/parsers.go | 360 ++++++ go/flightradarapi/parsers_test.go | 450 ++++++++ go/flightradarapi/ports_test.go | 295 +++++ go/flightradarapi/request.go | 728 ++++++++++++ go/flightradarapi/request_test.go | 1250 ++++++++++++++++++++ go/flightradarapi/snapshots_test.go | 452 ++++++++ go/flightradarapi/testdata/airlines.html | 60 + go/flightradarapi/testdata/airports.json | 77 ++ go/flightradarapi/values.go | 83 ++ go/flightradarapi/zones.go | 222 ++++ go/go.mod | 9 + go/go.sum | 8 + 31 files changed, 9883 insertions(+) create mode 100644 docs/go.md create mode 100644 go/LICENSE create mode 100644 go/Makefile create mode 100644 go/README.md create mode 100644 go/flightradarapi/airport.go create mode 100644 go/flightradarapi/api.go create mode 100644 go/flightradarapi/api_test.go create mode 100644 go/flightradarapi/cookies.go create mode 100644 go/flightradarapi/cookies_test.go create mode 100644 go/flightradarapi/core.go create mode 100644 go/flightradarapi/countries.go create mode 100644 go/flightradarapi/doc.go create mode 100644 go/flightradarapi/entities_test.go create mode 100644 go/flightradarapi/entity.go create mode 100644 go/flightradarapi/errors.go create mode 100644 go/flightradarapi/example_test.go create mode 100644 go/flightradarapi/flight.go create mode 100644 go/flightradarapi/flighttrackerconfig.go create mode 100644 go/flightradarapi/parsers.go create mode 100644 go/flightradarapi/parsers_test.go create mode 100644 go/flightradarapi/ports_test.go create mode 100644 go/flightradarapi/request.go create mode 100644 go/flightradarapi/request_test.go create mode 100644 go/flightradarapi/snapshots_test.go create mode 100644 go/flightradarapi/testdata/airlines.html create mode 100644 go/flightradarapi/testdata/airports.json create mode 100644 go/flightradarapi/values.go create mode 100644 go/flightradarapi/zones.go create mode 100644 go/go.mod create mode 100644 go/go.sum diff --git a/.gitignore b/.gitignore index c1736d8..e7f5f61 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ coverage.xml # Node.js node_modules/ +# Go +coverage.out + # Version managers (local dev only) .tool-versions .python-version diff --git a/docs/go.md b/docs/go.md new file mode 100644 index 0000000..4419d0c --- /dev/null +++ b/docs/go.md @@ -0,0 +1,251 @@ +--- +title: Go +description: API Documentation for Go +--- + +## Installation + +To install the FlightRadarAPI for Go, use the following command: + +```bash +go get github.com/JeanExtreme002/FlightRadarAPI/go@latest +``` + +## Basic Usage + +Import the package and create a client: + +```go +import "github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi" + +client := flightradarapi.New() + +// Every field's zero value means the default, so set only what you need. +client = flightradarapi.New(flightradarapi.Options{ + Timeout: 10 * time.Second, // default: 30s + MaxWorkers: 4, // default: 8 +}) +``` + +Construction cannot fail, so it needs no error handling. Every method that talks +to FlightRadar24 takes a `context.Context` and returns an `error` alongside its +result. + +### Fetching Data + +You can fetch various types of data using the following methods: + +- **Flights list:** + + ```go + flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{}) // Returns []*Flight + ``` + +- **Airports list:** + + ```go + // Airports of specific countries + airports, err := client.GetAirports(ctx, []flightradarapi.Country{ + flightradarapi.CountryBrazil, flightradarapi.CountryUnitedStates, + }) + + // Pass nil to get every airport + allAirports, err := client.GetAirports(ctx, nil) + ``` + +- **Airlines list:** + + ```go + airlines, err := client.GetAirlines(ctx) + ``` + +- **Zones list:** + + ```go + zones := client.GetZones() + ``` + +### Fetching Detailed Information + +Fetch more information about a specific flight or airport using the following methods: + +- **Flight details:** + + ```go + details, err := client.GetFlightDetails(ctx, flight) + flight.SetFlightDetails(details) + + fmt.Println("Flying to", flight.Details.DestinationAirportName) + ``` + + Ask for every flight's details in one call with `FlightSearch{Details: true}`. + Requests run `MaxWorkers` at a time (8 by default). + +- **Airport details:** + + ```go + details, err := client.GetAirportDetails(ctx, icao, 100, 1) + ``` + + !!! note + Arrivals and departures can have a limit `flightLimit` (max value is 100) to display. When you need to reach more than 100 flights you can use the `page` parameter to view other pages. + +## Advanced Usage + +### Fetching Flights Above a Specific Position + +Use the `GetBoundsByPoint(...)` method to fetch flights above a specific position. This method takes `latitude` and `longitude` for your position and `radius` for the distance in meters from your position to designate a tracking area. + +```go +// Your point is 52°34'04.7"N 13°16'57.5"E from Google Maps and radius 2km +bounds := client.GetBoundsByPoint(52.567967, 13.282644, 2000) + +flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{Bounds: bounds}) +``` + +### Filtering Flights and Airports + +Use the `GetFlights(...)` method to search for flights by airline, bounds (customized coordinates or obtained by the `GetZones()` method), aircraft registration or aircraft type. + +```go +// You may also set a custom region, such as: bounds := "73,-12,-156,38" +bounds := client.GetBounds(client.GetZones()["northamerica"]) + +emiratesFlights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{ + Airline: "UAE", + AircraftType: "B77W", + Bounds: bounds, +}) +``` + +A single flight can be checked against several values at once, with the optional +`min_`/`max_` prefixes for numeric comparisons: + +```go +matched, err := flight.CheckInfo(map[string]any{ + "min_altitude": 6700, "max_altitude": 13000, "airline_icao": "THY", +}) +``` + +### Fetching Airport by ICAO or IATA + +```go +luklaAirport, err := client.GetAirport(ctx, "VNLK", true) +``` + +### Calculating Distance Between Flights and Airports + +`Flight` and `Airport` both embed `Entity`, which provides the `GetDistanceFrom(...)` method. It returns the distance between the two entities in kilometers, and an error when either of them carries no position. + +```go +airport, err := client.GetAirport(ctx, "KJFK", false) +distance, err := flight.GetDistanceFrom(airport) + +fmt.Printf("The flight is %.1f km away from the airport.\n", distance) +``` + +### Downloading Flight Data :material-information-outline:{ title="This requires a premium subscription" } + +```go +if err := client.Login(ctx, "email", "password"); err != nil { + log.Fatal(err) +} + +historyData, err := client.GetHistoryData(ctx, flight, "CSV", 1706529600) +err = os.WriteFile("history_data.csv", []byte(historyData), 0o644) +``` + +!!! warning inline end + If an invalid time is provided, a blank document will be returned. + +| Parameter | Description | +| ------------- | ------------- | +| `flight` | The flight to download. This can be obtained from any other function that returns flights. | +| `fileType` | The format of the file to download. This can be either "CSV" or "KML". | +| `timestamp` | The scheduled time of departure (STD) of the flight in UTC, as a Unix timestamp. | + +### Setting and Getting Real-time Flight Tracker Parameters + +Set them with the `SetFlightTrackerConfig(...)` method. It takes a `*FlightTrackerConfig` and a map of single values; either may be `nil`. Unknown options and non-numeric values are rejected, and a rejected update leaves the current config untouched. + +Get the current configuration with `GetFlightTrackerConfig()`, which returns a copy. Note: `NewFlightTrackerConfig()` means resetting all parameters to default. + +```go +config := client.GetFlightTrackerConfig() +config.Limit = "10" + +err := client.SetFlightTrackerConfig(&config, nil) + +flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{}) // Returns only 10 flights +``` + +### Handling Errors + +Every error wraps a sentinel, so both `errors.Is` and `errors.As` work: + +```go +airport, err := client.GetAirport(ctx, "XXX", false) + +switch { +case errors.Is(err, flightradarapi.ErrAirportNotFound): + // no such airport +case errors.Is(err, flightradarapi.ErrCloudflare): + // blocked by Cloudflare +case errors.Is(err, flightradarapi.ErrLogin): + // the endpoint needs an account +} +``` + +Transient failures — a Cloudflare block, a timeout, a network error — can be +retried with exponential backoff: + +```go +retry, err := flightradarapi.NewRetryPolicy(3) +client := flightradarapi.New(flightradarapi.Options{Retry: retry}) +``` + +### TLS Impersonation + +FlightRadar24 fingerprints TLS handshakes through Cloudflare. The default client +narrows Go's offered cipher suites and curve order to Chrome's, which is enough +today. Since Go fixes its own cipher ordering, that is an approximation rather +than a byte-exact fingerprint; for full impersonation, pass a client built on +[utls](https://github.com/refraction-networking/utls) or +[tls-client](https://github.com/bogdanfinn/tls-client): + +```go +client := flightradarapi.New(flightradarapi.Options{ + HTTPClient: &http.Client{Transport: myImpersonatingTransport}, +}) +``` + +## Differences from the Python and Node.js Packages + +The behavior is the same; the surface is Go-shaped. + +The features are the same; the names differ only where Go's conventions do. The +package name is part of every identifier, so the client is `Client` rather than +`FlightRadar24API` — the way it is `http.Client` and not `http.HTTPClient`. + +| Python / Node.js | Go | +| --- | --- | +| `FlightRadar24API` | `Client`, built with `New` | +| `new FlightRadar24API({timeout, maxWorkers})` | `New(Options{Timeout: ..., MaxWorkers: ...})` | +| `FlightRadarError` (base class) | `ErrFlightRadar`, wrapped by every error here | +| `Countries.BRAZIL` | `CountryBrazil`, with `AllCountries()` to enumerate | +| `FlightRadar24API(user, password)` logs in (Python only) | `client.Login(ctx, user, password)` | +| `get_flights(airline, bounds, registration, aircraft_type, details)` | `GetFlights(ctx, FlightSearch{...})` | +| `check_info(min_altitude=6700)` | `CheckInfo(map[string]any{"min_altitude": 6700})` | +| `Airport.from_details(payload)` | `NewAirportFromDetails(payload)` | +| `(bytes, extension)` tuple | `*Image` | +| `airline["n_aircrafts"]` | `Airline.NumAircrafts` | +| `flight.destination_airport_name` | `flight.Details.DestinationAirportName` | +| default arguments (`flight_limit=100`, `limit=50`) | zero means the same default | +| `get_airports(None)` for every airport | `GetAirports(ctx, nil)` | +| exceptions | `error` values wrapping `ErrFlightRadar` | +| missing value is the string `"N/A"` | zero value: `""` or a nil pointer | +| `parsers` module (internal) | unexported functions | +| blocking calls | `context.Context` on every request | + +Every method of `FlightRadar24API` has a counterpart here, with the same +parameters, kept that way by a test that reads the Python source. diff --git a/go/LICENSE b/go/LICENSE new file mode 100644 index 0000000..fc867f2 --- /dev/null +++ b/go/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Jean Loui Bernard Silva de Jesus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/go/Makefile b/go/Makefile new file mode 100644 index 0000000..98314bb --- /dev/null +++ b/go/Makefile @@ -0,0 +1,142 @@ +# Makefile for the FlightRadarAPI Go package + +PACKAGE = ./flightradarapi +COVERAGE_FILE = coverage.out + +# Minimum offline coverage required by `test-coverage` and by the CI workflow. +# Keep in sync with `.github/workflows/go-package.yml`. +COVERAGE_MIN = 80 + +GO ?= go + +GREEN = \033[0;32m +YELLOW = \033[0;33m +NC = \033[0m + +.PHONY: help +help: + @echo "$(GREEN)FlightRadarAPI Go package Makefile$(NC)" + @echo "" + @echo "Available targets:" + @echo " $(YELLOW)deps$(NC) - Download and verify module dependencies" + @echo " $(YELLOW)build$(NC) - Compile the package" + @echo " $(YELLOW)test$(NC) - Run the offline tests (PR gate)" + @echo " $(YELLOW)test-verbose$(NC) - Run the offline tests with verbose output" + @echo " $(YELLOW)test-coverage$(NC) - Run the offline tests with a coverage report" + @echo " $(YELLOW)test-integration$(NC) - Run the live FR24 tests" + @echo " $(YELLOW)test-race$(NC) - Run the offline tests with the race detector" + @echo " $(YELLOW)lint$(NC) - Run gofmt and go vet" + @echo " $(YELLOW)lint-strict$(NC) - Run staticcheck on top of lint" + @echo " $(YELLOW)lint-fix$(NC) - Format the code with gofmt" + @echo " $(YELLOW)tidy$(NC) - Tidy go.mod and go.sum" + @echo " $(YELLOW)security$(NC) - Run a vulnerability scan (govulncheck)" + @echo " $(YELLOW)docs$(NC) - Show the package documentation" + @echo " $(YELLOW)clean$(NC) - Remove build and coverage artifacts" + @echo " $(YELLOW)version$(NC) - Show the package version" + @echo " $(YELLOW)all$(NC) - Run the full pipeline (deps, lint, test, build)" + +.PHONY: deps +deps: + @echo "$(GREEN)Downloading dependencies...$(NC)" + $(GO) mod download + $(GO) mod verify + +.PHONY: build +build: + @echo "$(GREEN)Building package...$(NC)" + $(GO) build ./... + +.PHONY: test +test: + @echo "$(GREEN)Running offline tests...$(NC)" + $(GO) test $(PACKAGE) + +.PHONY: test-verbose +test-verbose: + @echo "$(GREEN)Running offline tests with verbose output...$(NC)" + $(GO) test -v $(PACKAGE) + +# Coverage threshold lives in $(COVERAGE_MIN) so it stays in sync with CI. +.PHONY: test-coverage +test-coverage: + @echo "$(GREEN)Running offline tests with coverage (min $(COVERAGE_MIN)%)...$(NC)" + $(GO) test -coverprofile=$(COVERAGE_FILE) $(PACKAGE) + @$(GO) tool cover -func=$(COVERAGE_FILE) | tail -1 + @total=$$($(GO) tool cover -func=$(COVERAGE_FILE) | tail -1 | grep -oE '[0-9]+\.[0-9]+'); \ + if [ $$(echo "$$total < $(COVERAGE_MIN)" | bc -l) -eq 1 ]; then \ + echo "$(YELLOW)Coverage $$total% is below the $(COVERAGE_MIN)% minimum$(NC)"; exit 1; \ + fi + @echo "$(GREEN)Coverage report written to $(COVERAGE_FILE)$(NC)" + @echo "$(YELLOW)HTML report: go tool cover -html=$(COVERAGE_FILE)$(NC)" + +# Hits production FR24, so it is not part of `test`. +.PHONY: test-integration +test-integration: + @echo "$(GREEN)Running live FR24 tests...$(NC)" + $(GO) test -tags integration -run TestLive -v $(PACKAGE) + +.PHONY: test-race +test-race: + @echo "$(GREEN)Running offline tests with the race detector...$(NC)" + $(GO) test -race $(PACKAGE) + +.PHONY: lint +lint: + @echo "$(GREEN)Running gofmt and go vet...$(NC)" + @unformatted=$$(gofmt -l .); \ + if [ -n "$$unformatted" ]; then \ + echo "$(YELLOW)These files need gofmt:$(NC)"; echo "$$unformatted"; exit 1; \ + fi + $(GO) vet ./... + $(GO) vet -tags integration ./... + @echo "$(GREEN)Linting completed!$(NC)" + +# The counterpart of flake8/mypy on the Python side and eslint on the Node side: +# `go vet` alone is far thinner than either. +.PHONY: lint-strict +lint-strict: lint + @echo "$(GREEN)Running staticcheck...$(NC)" + $(GO) run honnef.co/go/tools/cmd/staticcheck@latest ./... + +.PHONY: lint-fix +lint-fix: + @echo "$(GREEN)Formatting code...$(NC)" + gofmt -w . + +.PHONY: tidy +tidy: + @echo "$(GREEN)Tidying modules...$(NC)" + $(GO) mod tidy + +# Uses govulncheck, the same tool the CI workflow runs. +.PHONY: security +security: + @echo "$(GREEN)Running vulnerability scan (govulncheck)...$(NC)" + $(GO) run golang.org/x/vuln/cmd/govulncheck@latest ./... + +.PHONY: docs +docs: + @echo "$(GREEN)Package documentation:$(NC)" + $(GO) doc -all $(PACKAGE) + +.PHONY: clean +clean: + @echo "$(GREEN)Cleaning artifacts...$(NC)" + rm -f $(COVERAGE_FILE) + $(GO) clean -cache -testcache ./... + +.PHONY: version +version: + @grep -E '^const Version' flightradarapi/doc.go | cut -d'"' -f2 + +.PHONY: all +all: deps lint test build + @echo "$(GREEN)Full pipeline completed successfully!$(NC)" + +.PHONY: pre-commit +pre-commit: lint test + @echo "$(GREEN)Pre-commit checks passed!$(NC)" + +.PHONY: ci +ci: deps lint-strict test-coverage build + @echo "$(GREEN)CI pipeline completed!$(NC)" diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000..d384bad --- /dev/null +++ b/go/README.md @@ -0,0 +1,277 @@ +# FlightRadarAPI +Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Go. + +This SDK should only be used for your own educational purposes. If you are interested in accessing Flightradar24 data commercially, please contact business@fr24.com. See more information at [Flightradar24's terms and conditions](https://www.flightradar24.com/terms-and-conditions). + +**Official FR24 API**: https://fr24api.flightradar24.com/ + +[![Go Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/go-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) +[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) +[![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) + +## Installing FlightRadarAPI + +```bash +go get github.com/JeanExtreme002/FlightRadarAPI/go@latest +``` + +The module lives in a subdirectory of the repository, so its releases are the +tags prefixed with it (`go/v1.6.0`). To track the branch instead: + +```bash +go get github.com/JeanExtreme002/FlightRadarAPI/go@main +``` + +## Basic Usage + +Create a client and call its methods. Construction cannot fail, so it needs no +error handling; every method that talks to FR24 takes a `context.Context` and +returns an error. + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi" +) + +func main() { + client := flightradarapi.New() + + flights, err := client.GetFlights(context.Background(), flightradarapi.FlightSearch{}) + if err != nil { + log.Fatal(err) + } + + for _, flight := range flights[:min(5, len(flights))] { + fmt.Println(flight, flight.GetFlightLevel()) + } +} +``` + +**Getting flights list:** +```go +flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{}) // Returns []*Flight +``` + +**Getting airports list:** +```go +// Get airports from specific countries +airports, err := client.GetAirports(ctx, []flightradarapi.Country{ + flightradarapi.CountryBrazil, flightradarapi.CountryUnitedStates, +}) + +// Pass nil to get every airport +allAirports, err := client.GetAirports(ctx, nil) +``` + +**Getting airlines list:** +```go +airlines, err := client.GetAirlines(ctx) // Returns []Airline with IATA/ICAO codes +``` + +**Getting zones list:** +```go +zones := client.GetZones() +``` + +**Using the Country constants:** +```go +flightradarapi.CountryUnitedStates // "united-states" +flightradarapi.CountryBrazil // "brazil" +flightradarapi.CountryGermany // "germany" +flightradarapi.CountryFrance // "france" +// ... and many more + +// Any spelling works — values are slugified before matching. +flightradarapi.Country("Myanmar (Burma)") // same filter as CountryMyanmarBurma + +// AllCountries() enumerates them, like list(Countries) does in Python. +for _, country := range flightradarapi.AllCountries() { … } +``` + +## Fetching Detailed Information + +```go +// Flight details +details, err := client.GetFlightDetails(ctx, flight) +flight.SetFlightDetails(details) +fmt.Println(flight.Details.AirlineName, flight.Details.OriginAirportName) + +// Every flight with its details, MaxWorkers requests at a time +flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{Details: true}) + +// Airport details +airport, err := client.GetAirport(ctx, "ATL", true) +fmt.Println(airport.Name, airport.TimezoneName, len(airport.Runways)) +``` + +## Advanced Usage + +**Fetching flights above a specific position:** +```go +// Your point is 52°34'04.7"N 13°16'57.5"E from Google Maps and radius 2 km +bounds := client.GetBoundsByPoint(52.567774, 13.282827, 2000) + +flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{Bounds: bounds}) +``` + +**Filtering flights and airports:** +```go +airportBounds := client.GetBounds(client.GetZones()["northamerica"]) +// Or set a custom region: bounds := "73,-12,-156,38" + +flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{ + Airline: "SWA", Bounds: airportBounds, AircraftType: "B738", +}) + +// Filter a flight on its values, with optional min_/max_ prefixes +matched, err := flight.CheckInfo(map[string]any{ + "min_altitude": 6700, "max_altitude": 13000, "airline_icao": "THY", +}) +``` + +**Calculating the distance between flights and airports:** +```go +distance, err := airport.GetDistanceFrom(flight) // In kilometers +``` + +**Downloading flight data** (requires a premium subscription): +```go +if err := client.Login(ctx, "email", "password"); err != nil { + log.Fatal(err) +} + +data, err := client.GetHistoryData(ctx, flight, "CSV", timestamp) +``` + +**Setting the Real Time Flight Tracker parameters:** +```go +// Replace the whole config, apply single values, or both +err := client.SetFlightTrackerConfig(nil, map[string]string{"limit": "10", "maxage": "600"}) + +config := client.GetFlightTrackerConfig() // A copy, safe to keep +``` + +**Configuring the client:** +```go +retry, err := flightradarapi.NewRetryPolicy(3) // 1s base, 30s cap, 500ms jitter + +// Every field's zero value means the default, so set only what you need. +client := flightradarapi.New(flightradarapi.Options{ + Timeout: 10 * time.Second, // default: 30s + MaxWorkers: 4, // default: 8 + Retry: retry, // default: no retry +}) +``` + +## Building Entities Yourself + +The constructors the Python and Node.js ports expose have counterparts here, for +payloads you already hold: + +```go +airport := flightradarapi.NewAirportFromBasicInfo(row) // one airports-feed row +airport = flightradarapi.NewAirportFromInfo(info) // the "details" block +airport = flightradarapi.NewAirportFromDetails(payload) // a GetAirportDetails payload +flight := flightradarapi.NewFlight("2e0f1a2", feedRow) // one live-feed row +``` + +## Error Handling + +Every error wraps a sentinel, so `errors.Is` and `errors.As` both work: + +```go +airport, err := client.GetAirport(ctx, "XXX", false) + +switch { +case errors.Is(err, flightradarapi.ErrAirportNotFound): + // no such airport +case errors.Is(err, flightradarapi.ErrCloudflare): + // blocked by Cloudflare — back off, or plug in TLS impersonation +case errors.Is(err, flightradarapi.ErrLogin): + // the endpoint needs an account +} + +var cloudflareErr *flightradarapi.CloudflareError + +if errors.As(err, &cloudflareErr) { + fmt.Println(string(cloudflareErr.Body)) // the challenge page +} +``` + +## TLS Impersonation + +FR24 fronts its site with Cloudflare, which fingerprints TLS handshakes. The +default client narrows Go's offered cipher suites and curve order to Chrome's +([`Chrome136Profile`](flightradarapi/request.go)), which is enough today. +Go fixes its own cipher ordering, so this is an approximation rather than a +byte-exact JA3. For full impersonation, plug in a client built on +[utls](https://github.com/refraction-networking/utls) or +[tls-client](https://github.com/bogdanfinn/tls-client): + +```go +client := flightradarapi.New(flightradarapi.Options{ + HTTPClient: &http.Client{Transport: myImpersonatingTransport}, +}) +``` + +Set `DisableCompression` on your transport so this package keeps owning content +decoding, and with it the response size budget. Leave `CheckRedirect` unset too, +or the cookies FR24 hands out on a redirect hop are not banked. + +## Differences from the Python and Node.js ports + +The features are the same; the names differ only where Go's conventions do. The +package name is part of every identifier, so the client is `Client` rather than +`FlightRadar24API` — the way it is `http.Client` and not `http.HTTPClient`. + +| Python / Node.js | Go | +| --- | --- | +| `FlightRadar24API` | `Client`, built with `New` | +| `new FlightRadar24API({timeout, maxWorkers})` | `New(Options{Timeout: ..., MaxWorkers: ...})` | +| `FlightRadarError` (base class) | `ErrFlightRadar`, wrapped by every error here | +| `Countries.BRAZIL` | `CountryBrazil`, with `AllCountries()` to enumerate | +| `FlightRadar24API(user, password)` logs in (Python only) | `client.Login(ctx, user, password)` | +| `get_flights(airline, bounds, registration, aircraft_type, details)` | `GetFlights(ctx, FlightSearch{...})` | +| `check_info(min_altitude=6700)` | `CheckInfo(map[string]any{"min_altitude": 6700})` | +| `Airport.from_details(payload)` | `NewAirportFromDetails(payload)` | +| `(bytes, extension)` tuple | `*Image` | +| `airline["n_aircrafts"]` | `Airline.NumAircrafts` | +| `flight.destination_airport_name` | `flight.Details.DestinationAirportName` | +| default arguments (`flight_limit=100`, `limit=50`) | zero means the same default | +| `get_airports(None)` for every airport | `GetAirports(ctx, nil)` | +| exceptions | `error` values wrapping `ErrFlightRadar` | +| missing value is the string `"N/A"` | zero value: `""` or a nil pointer | +| `parsers` module (internal) | unexported functions | +| blocking calls | `context.Context` on every request | + +Every method of `FlightRadar24API` has a counterpart here, with the same +parameters — `ports_test.go` reads the Python source and fails if that stops +being true. + +## Documentation + +Explore the documentation of the FlightRadarAPI package through +[this site](https://JeanExtreme002.github.io/FlightRadarAPI/), or read the +[Go reference](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi). + +## Development + +```bash +cd go +make deps +make test # offline suite (the PR gate) +make test-integration # live FR24 suite +make lint # gofmt + go vet +make lint-strict # adds staticcheck +make test-coverage +``` + +`countries.go` and `zones.go` are generated from the Python port; +`ports_test.go` fails when the two drift apart. diff --git a/go/flightradarapi/airport.go b/go/flightradarapi/airport.go new file mode 100644 index 0000000..59d1b78 --- /dev/null +++ b/go/flightradarapi/airport.go @@ -0,0 +1,226 @@ +package flightradarapi + +import "fmt" + +// basicAirportInfo is one row of the airports feed. +type basicAirportInfo struct { + Name string + ICAO string + IATA string + Latitude *float64 + Longitude *float64 + Altitude *float64 + Country string +} + +// Airport is an airport, with whatever detail the call that produced it carried. +// Fields past Country are filled in by [Client.GetAirport] with details, or by +// SetAirportDetails. +type Airport struct { + Entity + + Name string + ICAO string + IATA string + Altitude *float64 + Country string + + CountryCode string + CountryID *float64 + City string + + TimezoneName string + TimezoneOffset *float64 + TimezoneOffsetHours string + TimezoneAbbr string + TimezoneAbbrName string + + Visible *bool + Website string + Wikipedia string + + ReviewsURL string + Reviews *float64 + Evaluation *float64 + AverageRating *float64 + TotalRating *float64 + + Weather map[string]any + Runways []any + + AircraftOnGround *float64 + AircraftVisibleOnGround *float64 + + Arrivals map[string]any + Departures map[string]any + Images map[string]any + + // RawDetails is the payload the details came from, for fields this struct + // does not name. + RawDetails map[string]any +} + +// NewAirport returns an empty airport, to be filled in with SetAirportDetails. +func NewAirport() *Airport { return &Airport{} } + +// NewAirportFromBasicInfo builds an airport from one row of the airports feed, +// the counterpart of Airport.from_basic_info in the Python and Node.js ports. +// A row with only one usable coordinate carries no position at all. +func NewAirportFromBasicInfo(basicInfo map[string]any) *Airport { + latitude, longitude := toNumber(basicInfo["lat"]), toNumber(basicInfo["lon"]) + + if latitude == nil || longitude == nil { + latitude, longitude = nil, nil + } + + return newAirportFromBasicInfo(basicAirportInfo{ + Name: toText(basicInfo["name"]), + ICAO: toText(basicInfo["icao"]), + IATA: toText(basicInfo["iata"]), + Latitude: latitude, + Longitude: longitude, + Altitude: toNumber(basicInfo["alt"]), + Country: toText(basicInfo["country"]), + }) +} + +// NewAirportFromInfo builds an airport from the traffic-stats "details" block, +// the counterpart of Airport.from_info. +func NewAirportFromInfo(info map[string]any) *Airport { + return newAirportFromInfo(info) +} + +// NewAirportFromDetails builds an airport from a full [Client.GetAirportDetails] +// payload, the counterpart of Airport.from_details. +func NewAirportFromDetails(airportDetails map[string]any) *Airport { + airport := NewAirport() + airport.SetAirportDetails(airportDetails) + return airport +} + +func newAirportFromBasicInfo(info basicAirportInfo) *Airport { + airport := &Airport{ + Name: info.Name, + ICAO: info.ICAO, + IATA: info.IATA, + Altitude: info.Altitude, + Country: info.Country, + } + airport.setPosition(info.Latitude, info.Longitude) + return airport +} + +// newAirportFromInfo builds an airport from the traffic-stats "details" block. +func newAirportFromInfo(info map[string]any) *Airport { + position := getMap(info, "position") + code := getMap(info, "code") + country := getMap(position, "country") + region := getMap(position, "region") + timezone := getMap(info, "timezone") + + airport := &Airport{ + Name: getString(info, "name"), + ICAO: getString(code, "icao"), + IATA: getString(code, "iata"), + Altitude: getNumber(position, "altitude"), + + Country: getString(country, "name"), + CountryCode: getString(country, "code"), + City: getString(region, "city"), + + TimezoneName: getString(timezone, "name"), + TimezoneOffset: getNumber(timezone, "offset"), + TimezoneOffsetHours: getString(timezone, "offsetHours"), + TimezoneAbbr: getString(timezone, "abbr"), + TimezoneAbbrName: getString(timezone, "abbrName"), + + Visible: getBool(info, "visible"), + Website: getString(info, "website"), + + RawDetails: info, + } + airport.setPosition(getNumber(position, "latitude"), getNumber(position, "longitude")) + return airport +} + +func (a *Airport) String() string { + return fmt.Sprintf("<(%s) %s - Altitude: %s - Latitude: %s - Longitude: %s>", + a.ICAO, a.Name, formatOptional(a.Altitude), formatOptional(a.Latitude), + formatOptional(a.Longitude)) +} + +// SetAirportDetails fills the airport in from a [Client.GetAirportDetails] +// payload. +func (a *Airport) SetAirportDetails(airportDetails map[string]any) { + airport := getMap(getMap(airportDetails, "airport"), "pluginData") + details := getMap(airport, "details") + + position := getMap(details, "position") + code := getMap(details, "code") + country := getMap(position, "country") + region := getMap(position, "region") + + flightDiary := getMap(airport, "flightdiary") + ratings := getMap(flightDiary, "ratings") + schedule := getMap(airport, "schedule") + timezone := getMap(details, "timezone") + aircraftOnGround := getMap(getMap(airport, "aircraftCount"), "onGround") + urls := getMap(details, "url") + + a.RawDetails = airportDetails + + a.Name = getString(details, "name") + a.IATA = getString(code, "iata") + a.ICAO = getString(code, "icao") + a.Altitude = getNumber(position, "elevation") + a.setPosition(getNumber(position, "latitude"), getNumber(position, "longitude")) + + a.Country = getString(country, "name") + a.CountryCode = getString(country, "code") + a.CountryID = getNumber(country, "id") + a.City = getString(region, "city") + + a.TimezoneAbbr = getString(timezone, "abbr") + a.TimezoneAbbrName = getString(timezone, "abbrName") + a.TimezoneName = getString(timezone, "name") + a.TimezoneOffset = getNumber(timezone, "offset") + a.TimezoneOffsetHours = "" + + if a.TimezoneOffset != nil { + a.TimezoneOffsetHours = fmt.Sprintf("%d:00", int(*a.TimezoneOffset)/60/60) + } + + a.ReviewsURL = "" + + if path := getString(flightDiary, "url"); path != "" { + a.ReviewsURL = flightRadarBaseURL + path + } + + a.Reviews = getNumber(flightDiary, "reviews") + a.Evaluation = getNumber(flightDiary, "evaluation") + a.AverageRating = getNumber(ratings, "avg") + a.TotalRating = getNumber(ratings, "total") + + a.Weather = getMap(airport, "weather") + a.Runways = getSlice(airport, "runways") + + a.AircraftOnGround = getNumber(aircraftOnGround, "total") + a.AircraftVisibleOnGround = getNumber(aircraftOnGround, "visible") + + a.Arrivals = getMap(schedule, "arrivals") + a.Departures = getMap(schedule, "departures") + + a.Website = getString(urls, "homepage") + a.Wikipedia = getString(urls, "wikipedia") + + a.Visible = getBool(details, "visible") + a.Images = getMap(details, "airportImages") +} + +// formatOptional renders a number, or DefaultText when there is none. +func formatOptional(value *float64) string { + if value == nil { + return DefaultText + } + return formatNumber(*value) +} diff --git a/go/flightradarapi/api.go b/go/flightradarapi/api.go new file mode 100644 index 0000000..e93f501 --- /dev/null +++ b/go/flightradarapi/api.go @@ -0,0 +1,849 @@ +package flightradarapi + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "math" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Some FR24 live-feed backends answer 200 with a well-formed envelope but no +// flight entries, indistinguishable from a legitimately empty result. The +// AWSALB cookie then pins the session to that backend, so dropping it is what +// makes the load balancer re-roll on retry. +var feedStickyCookies = []string{"AWSALB", "AWSALBCORS"} + +const feedEmptyRetries = 4 + +// Defaults the other ports declare in their signatures, which Go cannot. +const ( + defaultFlightLimit = 100 + defaultSearchLimit = 50 +) + +// Client is the main entry point of the package, the counterpart of the +// FlightRadar24API class in the Python and Node.js SDKs. Build one with [New]. +type Client struct { + client *apiClient + endpoints endpoints + flightTrackerConfig FlightTrackerConfig + + mu sync.Mutex + loginData map[string]any + + // Timeout bounds a single request, and MaxWorkers the concurrent detail + // requests GetFlights makes. Set them through [New], or directly before the + // first call: they are read from the worker goroutines GetFlights spawns, so + // changing one while a request is in flight is a data race. + Timeout time.Duration + MaxWorkers int +} + +// Options configures a [Client]. Every field's zero value means the default, so +// Options{MaxWorkers: 4} changes only that one. +type Options struct { + // Timeout bounds a single request. Zero or less means [DefaultTimeout]; for + // a deadline of your own, cancel the context you pass to the call. + Timeout time.Duration + + // MaxWorkers bounds the concurrent detail requests [Client.GetFlights] + // makes. Zero or less means 8. + MaxWorkers int + + // Retry retries transient failures, including Cloudflare blocks. Nil means + // no retry. Build one with [NewRetryPolicy], which reports an unusable + // policy the way the Python and Node.js ports do. + Retry *RetryPolicy + + // TLSProfile overrides the TLS handshake the client presents. Use it when + // FR24 updates its Cloudflare bot mitigation faster than this library + // releases. Nil means [Chrome136Profile]. + TLSProfile *TLSProfile + + // HTTPClient replaces the whole HTTP client, which is how a real TLS + // impersonation library (utls, tls-client) is plugged in. Set + // Transport.DisableCompression so this package owns content decoding and its + // size budget stays enforceable. Leave CheckRedirect unset, or the cookies + // FR24 hands out on a redirect hop are not banked. Any Jar is ignored: this + // package renders the Cookie header itself. + HTTPClient *http.Client + + // endpoints points the client at another host, for tests. + endpoints *endpoints +} + +const defaultMaxWorkers = 8 + +// New returns a client, taking at most one [Options]; anything past the first is +// ignored. Call [Client.Login] for the endpoints that need an account. +// +// Construction cannot fail, so there is no error to handle: an unusable value +// falls back to the documented default. +func New(options ...Options) *Client { + config := Options{} + + if len(options) > 0 { + config = options[0] + } + + if config.Timeout <= 0 { + config.Timeout = DefaultTimeout + } + if config.MaxWorkers <= 0 { + config.MaxWorkers = defaultMaxWorkers + } + + httpClient := config.HTTPClient + + if httpClient == nil { + profile := Chrome136Profile() + + if config.TLSProfile != nil { + profile = *config.TLSProfile + } + httpClient = newHTTPClient(profile) + } + + resolved := defaultEndpoints() + + if config.endpoints != nil { + resolved = *config.endpoints + } + + return &Client{ + client: newAPIClient(httpClient, config.Retry), + endpoints: resolved, + flightTrackerConfig: NewFlightTrackerConfig(), + Timeout: config.Timeout, + MaxWorkers: config.MaxWorkers, + } +} + +// Image is a downloaded asset and the extension of the URL it came from. +type Image struct { + Data []byte + Extension string +} + +// GetAirlines returns every airline. +func (c *Client) GetAirlines(ctx context.Context) ([]Airline, error) { + response, err := c.client.request(ctx, c.endpoints.airlinesData, requestOptions{ + headers: htmlHeaders, + timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + return parseAirlinesHTML(response.Body), nil +} + +// GetAirlineLogo downloads the logo of an airline, or returns nil when FR24 has +// none. +func (c *Client) GetAirlineLogo(ctx context.Context, iata, icao string) (*Image, error) { + iata, icao = strings.ToUpper(iata), strings.ToUpper(icao) + notFound := []int{403, 404} + + for _, logoURL := range []string{ + c.endpoints.airlineLogoURL(iata, icao), + c.endpoints.alternativeAirlineLogoURL(icao), + } { + response, err := c.client.request(ctx, logoURL, requestOptions{ + headers: imageHeaders, + allowedErrorCodes: notFound, + timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + if response.StatusCode < 400 || response.StatusCode >= 500 { + return &Image{Data: response.Body, Extension: extensionOf(logoURL)}, nil + } + } + return nil, nil +} + +// GetAirport returns basic information about an airport. With details, it makes +// the extra call [Client.GetAirportDetails] does. +func (c *Client) GetAirport(ctx context.Context, code string, details bool) (*Airport, error) { + if len(code) < 3 || len(code) > 4 { + return nil, fmt.Errorf("%w: the code %q is invalid. It must be the IATA or ICAO of the airport", + ErrFlightRadar, code) + } + + if details { + airportDetails, err := c.GetAirportDetails(ctx, code, defaultFlightLimit, 1) + + if err != nil { + return nil, err + } + airport := NewAirport() + airport.SetAirportDetails(airportDetails) + return airport, nil + } + + response, err := c.client.request(ctx, c.endpoints.airportDataURL(code), requestOptions{ + headers: jsonHeaders, + timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + + content, err := response.JSON() + + if err != nil { + return nil, err + } + + info, ok := content["details"].(map[string]any) + + if !ok || len(info) == 0 { + return nil, &AirportNotFoundError{Code: code} + } + return newAirportFromInfo(info), nil +} + +// GetAirportDetails returns the full airport payload, with up to flightLimit +// flights from the given page of results. Zero means what the Python and +// Node.js ports default to: 100 flights, first page. +func (c *Client) GetAirportDetails(ctx context.Context, code string, flightLimit, page int) (map[string]any, error) { + if flightLimit <= 0 { + flightLimit = defaultFlightLimit + } + if page <= 0 { + page = 1 + } + if len(code) < 3 || len(code) > 4 { + return nil, fmt.Errorf("%w: the code %q is invalid. It must be the IATA or ICAO of the airport", + ErrFlightRadar, code) + } + + params := url.Values{} + params.Set("format", "json") + + if c.IsLoggedIn() { + if token, ok := c.client.getCookie("_frPl"); ok { + params.Set("token", token) + } + } + + params.Set("code", code) + params.Set("limit", fmt.Sprint(flightLimit)) + params.Set("page", fmt.Sprint(page)) + + response, err := c.client.request(ctx, c.endpoints.apiAirportData, requestOptions{ + params: params, + headers: jsonHeaders, + allowedErrorCodes: []int{400}, + timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + + content, err := response.JSON() + + if err != nil { + return nil, err + } + + if response.StatusCode == 400 && content["errors"] != nil { + parameters := getMap(getMap(getMap(content, "errors"), "errors"), "parameters") + + if limit := getMap(parameters, "limit"); len(limit) > 0 { + return nil, fmt.Errorf("%w: %s", ErrFlightRadar, getString(limit, "notBetween")) + } + return nil, &AirportNotFoundError{Code: code, Errors: parameters} + } + + result := getMap(getMap(content, "result"), "response") + data := getMap(getMap(result, "airport"), "pluginData") + + if _, ok := data["details"]; !ok && len(getSlice(data, "runways")) == 0 && len(data) <= 3 { + return nil, &AirportNotFoundError{Code: code} + } + return result, nil +} + +// GetAirportDisruptions returns the current airport disruptions. +func (c *Client) GetAirportDisruptions(ctx context.Context) (map[string]any, error) { + return c.jsonRequest(ctx, c.endpoints.airportDisruption) +} + +// GetAirports returns every airport, or only those of the given countries. Pass +// nil for every airport; an empty non-nil slice selects none. +func (c *Client) GetAirports(ctx context.Context, countries []Country) ([]*Airport, error) { + if countries != nil && len(countries) == 0 { + return []*Airport{}, nil + } + + response, err := c.client.request(ctx, c.endpoints.airportsJSON, requestOptions{ + headers: jsonHeaders, + timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + + // The raw body, not JSON(): an HTML body reaches the parser's guard, and a + // malformed JSON body still returns empty rather than failing the call. + return parseAirportsJSON(response.Body, countries), nil +} + +// GetBookmarks returns the bookmarks of the logged-in account. +func (c *Client) GetBookmarks(ctx context.Context) (map[string]any, error) { + headers, err := c.authHeaders() + + if err != nil { + return nil, err + } + + response, err := c.client.request(ctx, c.endpoints.bookmarks, requestOptions{ + headers: headers, + timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + return response.JSON() +} + +// GetBounds renders a zone as the "y1,y2,x1,x2" string the feed expects. +func (c *Client) GetBounds(zone Zone) string { + return fmt.Sprintf("%v,%v,%v,%v", zone.TLY, zone.BRY, zone.TLX, zone.BRX) +} + +// GetBoundsByPoint renders the square of the given radius (in meters) around a +// point as the "y1,y2,x1,x2" string the feed expects. +func (c *Client) GetBoundsByPoint(latitude, longitude, radius float64) string { + halfSideInKm := math.Abs(radius) / 1000 + + lat := radians(latitude) + lon := radians(longitude) + + const approxEarthRadius = 6371 + + // Distance from the centre to a corner of the bounding square. + hypotenuse := math.Sqrt(2 * math.Pow(halfSideInKm, 2)) + + // The diagonal bearings: 225° for the south-west corner (min lat/lon), 45° + // for the north-east one (max lat/lon). + corner := func(bearing float64) (float64, float64) { + angular := hypotenuse / approxEarthRadius + + cornerLat := math.Asin(math.Sin(lat)*math.Cos(angular) + + math.Cos(lat)*math.Sin(angular)*math.Cos(bearing)) + cornerLon := lon + math.Atan2( + math.Sin(bearing)*math.Sin(angular)*math.Cos(lat), + math.Cos(angular)-math.Sin(lat)*math.Sin(cornerLat)) + + return cornerLat, cornerLon + } + + latMin, lonMin := corner(radians(225)) + latMax, lonMax := corner(radians(45)) + + return c.GetBounds(Zone{ + TLY: degrees(latMax), + BRY: degrees(latMin), + TLX: degrees(lonMin), + BRX: degrees(lonMax), + }) +} + +// GetCountryFlag downloads the flag of a country, or returns nil when FR24 has +// none. +func (c *Client) GetCountryFlag(ctx context.Context, country string) (*Image, error) { + // The same slugifier as the feed, which spells some names "Myanmar (Burma)". + slug := countryToSlug(country) + + if slug == "" { + return nil, nil + } + + flagURL := c.endpoints.countryFlagURL(slug) + headers := maps.Clone(imageHeaders) + + delete(headers, "origin") // Does not work for this request. + + response, err := c.client.request(ctx, flagURL, requestOptions{ + headers: headers, + allowedErrorCodes: []int{403, 404}, + timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + if response.StatusCode >= 400 && response.StatusCode < 500 { + return nil, nil + } + return &Image{Data: response.Body, Extension: extensionOf(flagURL)}, nil +} + +// GetFlightDetails returns the details payload of a flight. +func (c *Client) GetFlightDetails(ctx context.Context, flight *Flight) (map[string]any, error) { + response, err := c.client.requestStandalone(ctx, c.endpoints.flightDataURL(flight.ID), requestOptions{ + headers: jsonHeaders, + timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + return response.JSON() +} + +// FlightSearch narrows the flights [Client.GetFlights] returns. See +// [Client.SetFlightTrackerConfig] for the rest of the options. +type FlightSearch struct { + // Airline is an airline ICAO, e.g. "DAL". + Airline string + // Bounds is a "y1,y2,x1,x2" string, e.g. "75.78,-75.78,-427.56,427.56". + Bounds string + // Registration is an aircraft registration. + Registration string + // AircraftType is an aircraft model code, e.g. "B737". + AircraftType string + // Details fetches the details of every flight found, MaxWorkers at a time. + Details bool +} + +// GetFlights returns the flights the live feed reports for the given search. +func (c *Client) GetFlights(ctx context.Context, search FlightSearch) ([]*Flight, error) { + params := c.GetFlightTrackerConfig().Values() + + if c.IsLoggedIn() { + if token, ok := c.client.getCookie("_frPl"); ok { + params.Set("enc", token) + } + } + + if search.Airline != "" { + params.Set("airline", search.Airline) + } + if search.Bounds != "" { + params.Set("bounds", search.Bounds) + } + if search.Registration != "" { + params.Set("reg", search.Registration) + } + if search.AircraftType != "" { + params.Set("type", search.AircraftType) + } + + var flights []*Flight + + for range feedEmptyRetries + 1 { + response, err := c.client.request(ctx, c.endpoints.realTimeFlightTrackerData, requestOptions{ + params: params, + headers: jsonHeaders, + timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + + content, err := response.JSON() + + if err != nil { + return nil, err + } + flights = flightsFromFeed(content) + + // "full_count": 0 means the feed really has nothing to report. + fullCount := toNumber(content["full_count"]) + + if len(flights) > 0 || fullCount == nil || *fullCount == 0 { + break + } + + for _, name := range feedStickyCookies { + c.client.deleteCookie(name) + } + } + + if search.Details { + if err := c.fetchDetails(ctx, flights); err != nil { + return flights, err + } + } + return flights, nil +} + +// flightsFromFeed keeps the feed entries that are flights, skipping the +// envelope's own keys. +func flightsFromFeed(content map[string]any) []*Flight { + flights := make([]*Flight, 0, len(content)) + + for flightID, info := range content { + if flightID == "" || flightID[0] < '0' || flightID[0] > '9' { + continue + } + if row, ok := info.([]any); ok { + flights = append(flights, newFlight(flightID, row)) + } + } + return flights +} + +// fetchDetails fills in every flight's details, MaxWorkers at a time. +func (c *Client) fetchDetails(ctx context.Context, flights []*Flight) error { + if len(flights) == 0 { + return nil + } + + // Clamped rather than skipped: MaxWorkers is public, and a zero there must + // not turn "with details" into a silent no-op. + workers := min(max(c.MaxWorkers, 1), len(flights)) + + queue := make(chan *Flight) + var group sync.WaitGroup + var once sync.Once + var firstErr error + + for range workers { + group.Add(1) + + go func() { + defer group.Done() + + for flight := range queue { + details, err := c.GetFlightDetails(ctx, flight) + + if err != nil { + once.Do(func() { firstErr = err }) + continue + } + flight.SetFlightDetails(details) + } + }() + } + + for _, flight := range flights { + queue <- flight + } + close(queue) + group.Wait() + + return firstErr +} + +// GetFlightTrackerConfig returns a copy of the current Real Time Flight Tracker +// config, used by [Client.GetFlights]. +func (c *Client) GetFlightTrackerConfig() FlightTrackerConfig { + c.mu.Lock() + defer c.mu.Unlock() + return c.flightTrackerConfig +} + +// SetFlightTrackerConfig replaces the config, then applies values on top of it. +// Either argument may be nil. +func (c *Client) SetFlightTrackerConfig(config *FlightTrackerConfig, values map[string]string) error { + c.mu.Lock() + defer c.mu.Unlock() + + updated := c.flightTrackerConfig + + if config != nil { + updated = *config + } + if err := updated.update(values); err != nil { + return err + } + c.flightTrackerConfig = updated + return nil +} + +// GetHistoryData downloads the historical data of a flight. fileType must be +// "CSV" or "KML". Requires a premium account. +func (c *Client) GetHistoryData(ctx context.Context, flight *Flight, fileType string, timestamp int64) (string, error) { + headers, err := c.authHeaders() + + if err != nil { + return "", err + } + + fileType = strings.ToLower(fileType) + + if fileType != "csv" && fileType != "kml" { + return "", fmt.Errorf("%w: file type %q is not supported. Only CSV and KML are supported", + ErrFlightRadar, fileType) + } + + response, err := c.client.request(ctx, c.endpoints.historicalDataURL(flight.ID, fileType, timestamp), + requestOptions{headers: headers, timeout: c.Timeout}) + + if err != nil { + return "", err + } + return string(response.Body), nil +} + +// GetLoginData returns the data of the logged-in account. +func (c *Client) GetLoginData() (map[string]any, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.loginData == nil { + return nil, &LoginError{Message: "you must log in to your account"} + } + return maps.Clone(getMap(c.loginData, "userData")), nil +} + +// GetMostTracked returns the most tracked flights. +func (c *Client) GetMostTracked(ctx context.Context) (map[string]any, error) { + return c.jsonRequest(ctx, c.endpoints.mostTracked) +} + +// GetVolcanicEruptions returns the boundaries of volcanic eruptions and ash +// clouds impacting aviation. +func (c *Client) GetVolcanicEruptions(ctx context.Context) (map[string]any, error) { + return c.jsonRequest(ctx, c.endpoints.volcanicEruptionData) +} + +// GetZones returns every major zone on the globe. +func (c *Client) GetZones() map[string]Zone { + zones := make(map[string]Zone, len(staticZones)) + + for name, zone := range staticZones { + zones[name] = cloneZone(zone) + } + return zones +} + +func cloneZone(zone Zone) Zone { + if zone.Subzones == nil { + return zone + } + + clone := zone + clone.Subzones = make(map[string]Zone, len(zone.Subzones)) + + for name, subzone := range zone.Subzones { + clone.Subzones[name] = cloneZone(subzone) + } + return clone +} + +// Search returns the search results, grouped as FR24 counts them. A limit of +// zero means the 50 the Python and Node.js ports default to. +func (c *Client) Search(ctx context.Context, query string, limit int) (map[string][]any, error) { + if limit <= 0 { + limit = defaultSearchLimit + } + + response, err := c.client.request(ctx, c.endpoints.searchDataURL(query, limit), requestOptions{ + headers: jsonHeaders, + timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + + if !response.IsJSON() { + return nil, fmt.Errorf("%w: expected JSON response from %s, got %q", + ErrFlightRadar, response.URL, response.Header.Get("Content-Type")) + } + + var payload struct { + Results []any `json:"results"` + Stats struct { + Count json.RawMessage `json:"count"` + } `json:"stats"` + } + + if err := json.Unmarshal([]byte(response.Text()), &payload); err != nil { + return nil, fmt.Errorf("%w: could not parse the search response: %w", ErrFlightRadar, err) + } + + // The counts slice `results` in the order FR24 lists them, so the object's + // key order is what makes the groups line up. + counts, err := orderedCounts(payload.Stats.Count) + + if err != nil { + return nil, err + } + + groups := make(map[string][]any, len(counts)) + index := 0 + + for _, entry := range counts { + end := min(index+entry.count, len(payload.Results)) + + if end < index { + end = index + } + groups[entry.name] = payload.Results[index:end] + index = end + } + return groups, nil +} + +type namedCount struct { + name string + count int +} + +// orderedCounts reads a JSON object of counts, keeping its key order. +func orderedCounts(raw json.RawMessage) ([]namedCount, error) { + if len(raw) == 0 { + return nil, nil + } + + decoder := json.NewDecoder(strings.NewReader(string(raw))) + token, err := decoder.Token() + + if err != nil { + return nil, fmt.Errorf("%w: could not parse the search counts: %w", ErrFlightRadar, err) + } + if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { + return nil, nil + } + + var counts []namedCount + + for decoder.More() { + key, err := decoder.Token() + + if err != nil { + return nil, fmt.Errorf("%w: could not parse the search counts: %w", ErrFlightRadar, err) + } + + var count float64 + + if err := decoder.Decode(&count); err != nil { + return nil, fmt.Errorf("%w: could not parse the search counts: %w", ErrFlightRadar, err) + } + + name, _ := key.(string) + counts = append(counts, namedCount{name: name, count: int(count)}) + } + return counts, nil +} + +// IsLoggedIn reports whether the client holds a FlightRadar24 session. +func (c *Client) IsLoggedIn() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.loginData != nil +} + +// Login logs in to a FlightRadar24 account. +func (c *Client) Login(ctx context.Context, user, password string) error { + c.mu.Lock() + c.loginData = nil + c.mu.Unlock() + c.client.clearCookies() + + data := url.Values{} + data.Set("email", user) + data.Set("password", password) + data.Set("remember", "true") + data.Set("type", "web") + + response, err := c.client.request(ctx, c.endpoints.userLogin, requestOptions{ + headers: jsonHeaders, + data: data, + timeout: c.Timeout, + }) + + if err != nil { + return err + } + + content, err := response.JSON() + + if err != nil { + return err + } + + success, _ := content["success"].(bool) + + if !success { + message := getString(content, "message") + + if message == "" { + message = "your email or password is incorrect" + } + return &LoginError{Message: message} + } + + c.mu.Lock() + c.loginData = map[string]any{"userData": getMap(content, "userData")} + c.mu.Unlock() + + return nil +} + +// Logout ends the FlightRadar24 session, reporting whether the server confirmed +// it. +func (c *Client) Logout(ctx context.Context) (bool, error) { + if !c.IsLoggedIn() { + return true, nil + } + + c.mu.Lock() + c.loginData = nil + c.mu.Unlock() + + defer c.client.clearCookies() + + response, err := c.client.request(ctx, c.endpoints.userLogout, requestOptions{ + headers: jsonHeaders, + timeout: c.Timeout, + }) + + // The local session is gone either way; the error says the server never + // confirmed it, which a caller may want to retry or log. + if err != nil { + return false, err + } + return response.StatusCode >= 200 && response.StatusCode < 300, nil +} + +// jsonRequest is the shape of the endpoints that just return a JSON object. +func (c *Client) jsonRequest(ctx context.Context, target string) (map[string]any, error) { + response, err := c.client.request(ctx, target, requestOptions{ + headers: jsonHeaders, + timeout: c.Timeout, + }) + + if err != nil { + return nil, err + } + return response.JSON() +} + +// authHeaders are the JSON headers plus the access token of the logged-in +// account. +func (c *Client) authHeaders() (map[string]string, error) { + userData, err := c.GetLoginData() + + if err != nil { + return nil, err + } + return withHeaders(jsonHeaders, map[string]string{ + "accesstoken": getString(userData, "accessToken"), + }), nil +} + +// extensionOf returns the extension a URL's file carries. +func extensionOf(target string) string { + parts := strings.Split(target, ".") + return parts[len(parts)-1] +} diff --git a/go/flightradarapi/api_test.go b/go/flightradarapi/api_test.go new file mode 100644 index 0000000..365d1c7 --- /dev/null +++ b/go/flightradarapi/api_test.go @@ -0,0 +1,1331 @@ +package flightradarapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" +) + +// One real feed row, trimmed to the positional fields Flight reads. +var flightRow = []any{ + "ABC123", -23.43, -46.47, 90.0, 35000.0, 450.0, "1234", "", "B738", "PR-XYZ", + 1700000000.0, "GRU", "GIG", "G31234", 0.0, 0.0, "GLO1234", 0.0, "GLO", +} + +var ( + degradedFeed = map[string]any{ + "full_count": 22684, + "version": 4, + "stats": map[string]any{"total": map[string]any{"ads-b": 18541}}, + } + healthyFeed = map[string]any{ + "full_count": 24560, + "version": 4, + "3f6a31cd": flightRow, + "40ae422e": flightRow, + } + idleFeed = map[string]any{"full_count": 0, "version": 4} +) + +// testEndpoints points every URL at a local server, keeping the real paths. +func testEndpoints(base string) endpoints { + return endpoints{ + userLogin: base + "/user/login", + userLogout: base + "/user/logout", + searchData: base + "/v1/search/web/find?query=%s&limit=%d", + realTimeFlightTrackerData: base + "/zones/fcgi/feed.js", + flightData: base + "/clickhandler/?flight=%s", + historicalData: base + "/download/?flight=%s&file=%s&trailLimit=0&history=%d", + apiAirportData: base + "/common/v1/airport.json", + airportData: base + "/airports/traffic-stats/?airport=%s", + airportsJSON: base + "/_json/airports.php", + airportDisruption: base + "/webapi/v1/airport-disruptions", + airlinesData: base + "/data/airlines", + volcanicEruptionData: base + "/weather/volcanic", + mostTracked: base + "/flights/most-tracked", + bookmarks: base + "/webapi/v1/bookmarks", + countryFlag: base + "/static/images/data/flags-small/%s.svg", + airlineLogo: base + "/assets/airlines/logotypes/%s_%s.png", + alternativeAirlineLogo: base + "/static/images/data/operators/%s_logo0.png", + } +} + +// newTestClient serves handler and returns a client wired to it. +func newTestClient(t *testing.T, handler http.Handler) *Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + endpoints := testEndpoints(server.URL) + + return New(Options{Timeout: 5 * time.Second, endpoints: &endpoints}) +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Errorf("could not write the payload: %v", err) + } +} + +// --- airlines, airports, zones --- + +func TestGetAirlinesParsesTheListingPage(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/data/airlines", func(w http.ResponseWriter, r *http.Request) { + if accept := r.Header.Get("Accept"); !strings.Contains(accept, "text/html") { + t.Errorf("got accept %q, want the html headers", accept) + } + w.Header().Set("Content-Type", "text/html") + w.Write(loadFixture(t, "airlines.html")) + }) + + airlines, err := newTestClient(t, mux).GetAirlines(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(airlines) != 5 { + t.Errorf("got %d airlines, want 5", len(airlines)) + } +} + +func TestGetAirportsFiltersByCountry(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/_json/airports.php", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(loadFixture(t, "airports.json")) + }) + + client := newTestClient(t, mux) + airports, err := client.GetAirports(context.Background(), []Country{CountryBrazil}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(airports) != 4 { + t.Errorf("got %d Brazilian airports, want 4", len(airports)) + } + + every, err := client.GetAirports(context.Background(), nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(every) != 8 { + t.Errorf("got %d airports for nil, want all 8", len(every)) + } +} + +func TestGetAirportsWithAnEmptyFilterMakesNoRequest(t *testing.T) { + var calls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/_json/airports.php", func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + w.Write(loadFixture(t, "airports.json")) + }) + + airports, err := newTestClient(t, mux).GetAirports(context.Background(), []Country{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(airports) != 0 || calls.Load() != 0 { + t.Errorf("got %d airports after %d calls, want neither", len(airports), calls.Load()) + } +} + +func TestGetAirportReadsTheDetailsBlock(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/airports/traffic-stats/", func(w http.ResponseWriter, r *http.Request) { + if code := r.URL.Query().Get("airport"); code != "ATL" { + t.Errorf("got airport %q, want ATL", code) + } + writeJSON(t, w, map[string]any{"details": map[string]any{ + "name": "Hartsfield Jackson Atlanta International Airport", + "code": map[string]any{"iata": "ATL", "icao": "KATL"}, + "position": map[string]any{"latitude": 33.6367, "longitude": -84.428101}, + }}) + }) + + airport, err := newTestClient(t, mux).GetAirport(context.Background(), "ATL", false) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if airport.IATA != "ATL" || airport.ICAO != "KATL" { + t.Errorf("got %q / %q", airport.IATA, airport.ICAO) + } +} + +func TestGetAirportReportsAMissingAirport(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/airports/traffic-stats/", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]any{}) + }) + + _, err := newTestClient(t, mux).GetAirport(context.Background(), "XXX", false) + + if !errors.Is(err, ErrAirportNotFound) { + t.Errorf("got %v, want an airport-not-found error", err) + } +} + +func TestGetAirportRejectsAnInvalidCode(t *testing.T) { + client := newTestClient(t, http.NewServeMux()) + + for _, code := range []string{"", "AB", "TOOLONG"} { + if _, err := client.GetAirport(context.Background(), code, false); err == nil { + t.Errorf("%q: expected an error", code) + } + } +} + +func TestGetAirportDetailsReturnsTheResponseBlock(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/common/v1/airport.json", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + if query.Get("code") != "ATL" || query.Get("limit") != "10" || query.Get("page") != "2" { + t.Errorf("got query %v", query) + } + writeJSON(t, w, map[string]any{"result": map[string]any{"response": map[string]any{ + "airport": map[string]any{"pluginData": map[string]any{ + "details": map[string]any{"name": "Atlanta"}, + }}, + }}}) + }) + + details, err := newTestClient(t, mux).GetAirportDetails(context.Background(), "ATL", 10, 2) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + airport := getMap(getMap(getMap(details, "airport"), "pluginData"), "details") + + if getString(airport, "name") != "Atlanta" { + t.Errorf("got %v", details) + } +} + +func TestGetAirportDetailsReportsAFlightLimitOutOfRange(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/common/v1/airport.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{"errors": map[string]any{ + "errors": map[string]any{"parameters": map[string]any{ + "limit": map[string]any{"notBetween": "limit must be between 1 and 100"}, + }}, + }}) + }) + + _, err := newTestClient(t, mux).GetAirportDetails(context.Background(), "ATL", 5000, 1) + + if err == nil || !strings.Contains(err.Error(), "between 1 and 100") { + t.Errorf("got %v, want the limit message", err) + } + if errors.Is(err, ErrAirportNotFound) { + t.Error("a bad limit is not a missing airport") + } +} + +func TestGetAirportDetailsReportsAnUnknownCode(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/common/v1/airport.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{"errors": map[string]any{ + "errors": map[string]any{"parameters": map[string]any{ + "code": map[string]any{"notFound": "not found"}, + }}, + }}) + }) + + _, err := newTestClient(t, mux).GetAirportDetails(context.Background(), "XXX", 100, 1) + + var notFound *AirportNotFoundError + + if !errors.As(err, ¬Found) { + t.Fatalf("got %v, want an AirportNotFoundError", err) + } + if len(notFound.Errors) == 0 { + t.Error("the validation payload must be carried on the error") + } +} + +func TestGetAirportDetailsRejectsASparsePayload(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/common/v1/airport.json", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]any{"result": map[string]any{"response": map[string]any{ + "airport": map[string]any{"pluginData": map[string]any{ + "schedule": map[string]any{}, "weather": map[string]any{}, + }}, + }}}) + }) + + if _, err := newTestClient(t, mux).GetAirportDetails(context.Background(), "XXX", 100, 1); !errors.Is(err, ErrAirportNotFound) { + t.Errorf("got %v, want an airport-not-found error", err) + } +} + +func TestGetZonesReturnsACopy(t *testing.T) { + client := newTestClient(t, http.NewServeMux()) + zones := client.GetZones() + + if len(zones) == 0 { + t.Fatal("no zones returned") + } + + europe := zones["europe"] + + if europe.TLY == 0 || len(europe.Subzones) == 0 { + t.Errorf("got %+v, want the bundled europe zone", europe) + } + + delete(zones, "europe") + delete(zones["northamerica"].Subzones, "na_n") + + fresh := client.GetZones() + + if _, ok := fresh["europe"]; !ok { + t.Error("mutating the result must not touch the bundled zones") + } + if _, ok := fresh["northamerica"].Subzones["na_n"]; !ok { + t.Error("mutating a subzone map must not touch the bundled zones") + } +} + +func TestGetBoundsRendersTheZone(t *testing.T) { + client := newTestClient(t, http.NewServeMux()) + bounds := client.GetBounds(Zone{TLY: 72.57, TLX: -16.96, BRY: 33.57, BRX: 53.05}) + + if bounds != "72.57,33.57,-16.96,53.05" { + t.Errorf("got %q", bounds) + } +} + +func TestGetBoundsByPointSurroundsThePoint(t *testing.T) { + client := newTestClient(t, http.NewServeMux()) + bounds := client.GetBoundsByPoint(52.567774, 13.282827, 2000) + parts := strings.Split(bounds, ",") + + if len(parts) != 4 { + t.Fatalf("got %q, want four values", bounds) + } + + var north, south, west, east float64 + + if _, err := fmt.Sscanf(bounds, "%f,%f,%f,%f", &north, &south, &west, &east); err != nil { + t.Fatal(err) + } + if !(south < 52.567774 && 52.567774 < north) || !(west < 13.282827 && 13.282827 < east) { + t.Errorf("got %q, want a box around the point", bounds) + } + if north-south > 0.1 || east-west > 0.1 { + t.Errorf("got %q, want a box of about 4 km across", bounds) + } +} + +// --- flights --- + +func TestGetFlightsParsesTheFeed(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/zones/fcgi/feed.js", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + if query.Get("airline") != "GLO" || query.Get("bounds") != "75,3,-180,-52" { + t.Errorf("got query %v", query) + } + if query.Get("limit") != "5000" || query.Get("maxage") != "14400" { + t.Errorf("the tracker config must be sent: %v", query) + } + writeJSON(t, w, healthyFeed) + }) + + flights, err := newTestClient(t, mux).GetFlights(context.Background(), FlightSearch{ + Airline: "GLO", Bounds: "75,3,-180,-52", + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(flights) != 2 { + t.Fatalf("got %d flights, want 2", len(flights)) + } + for _, flight := range flights { + if flight.Registration != "PR-XYZ" || flight.AirlineICAO != "GLO" { + t.Errorf("got %+v", flight) + } + } +} + +func TestGetFlightsFetchesDetailsConcurrently(t *testing.T) { + var detailCalls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/zones/fcgi/feed.js", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, healthyFeed) + }) + mux.HandleFunc("/clickhandler/", func(w http.ResponseWriter, r *http.Request) { + detailCalls.Add(1) + writeJSON(t, w, map[string]any{ + "aircraft": map[string]any{"model": map[string]any{"text": "Boeing 737-800"}}, + "status": map[string]any{"text": "Landed"}, + "trail": []any{}, + }) + }) + + flights, err := newTestClient(t, mux).GetFlights(context.Background(), FlightSearch{Details: true}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if detailCalls.Load() != 2 { + t.Errorf("got %d detail calls, want 2", detailCalls.Load()) + } + for _, flight := range flights { + if flight.Details == nil || flight.Details.AircraftModel != "Boeing 737-800" { + t.Errorf("details missing on %s", flight.ID) + } + } +} + +func TestGetFlightsRetriesADegradedFeed(t *testing.T) { + var calls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/zones/fcgi/feed.js", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Set-Cookie", "AWSALB=sticky; Path=/") + w.Header().Add("Set-Cookie", "_frPl=login-token; Path=/") + + if calls.Add(1) == 1 { + writeJSON(t, w, degradedFeed) + return + } + writeJSON(t, w, healthyFeed) + }) + + client := newTestClient(t, mux) + flights, err := client.GetFlights(context.Background(), FlightSearch{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(flights) != 2 { + t.Errorf("got %d flights, want 2", len(flights)) + } + if calls.Load() != 2 { + t.Errorf("got %d feed calls, want 2", calls.Load()) + } + // Shedding the stickiness must not log the user out. + if _, ok := client.client.getCookie("_frPl"); !ok { + t.Error("the login cookie must survive the retry") + } +} + +func TestGetFlightsGivesUpOnAFeedThatNeverRecovers(t *testing.T) { + var calls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/zones/fcgi/feed.js", func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeJSON(t, w, degradedFeed) + }) + + flights, err := newTestClient(t, mux).GetFlights(context.Background(), FlightSearch{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(flights) != 0 { + t.Errorf("got %d flights, want none", len(flights)) + } + // A permanently degraded upstream must not loop forever. + if calls.Load() <= 1 || calls.Load() > 6 { + t.Errorf("got %d feed calls, want between 2 and 6", calls.Load()) + } +} + +func TestGetFlightsDoesNotRetryWhenNothingIsTracked(t *testing.T) { + var calls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/zones/fcgi/feed.js", func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeJSON(t, w, idleFeed) + }) + + flights, err := newTestClient(t, mux).GetFlights(context.Background(), FlightSearch{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(flights) != 0 || calls.Load() != 1 { + t.Errorf("got %d flights after %d calls, want none after one", len(flights), calls.Load()) + } +} + +func TestGetFlightsKeepsFiltersAcrossRetries(t *testing.T) { + var calls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/zones/fcgi/feed.js", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("airline") != "GLO" || r.URL.Query().Get("reg") != "PR-XYZ" { + t.Errorf("call %d lost its filters: %v", calls.Load()+1, r.URL.Query()) + } + if calls.Add(1) < 3 { + writeJSON(t, w, degradedFeed) + return + } + writeJSON(t, w, healthyFeed) + }) + + _, err := newTestClient(t, mux).GetFlights(context.Background(), FlightSearch{ + Airline: "GLO", Registration: "PR-XYZ", + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calls.Load() != 3 { + t.Errorf("got %d feed calls, want 3", calls.Load()) + } +} + +func TestFlightTrackerConfigRoundTrip(t *testing.T) { + client := newTestClient(t, http.NewServeMux()) + + if err := client.SetFlightTrackerConfig(nil, map[string]string{"limit": "10", "maxage": "600"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + config := client.GetFlightTrackerConfig() + + if config.Limit != "10" || config.MaxAge != "600" || config.FAA != "1" { + t.Errorf("got %+v", config) + } + + // The getter must hand back a copy. + config.Limit = "999" + + if client.GetFlightTrackerConfig().Limit != "10" { + t.Error("mutating the returned config must not change the client's") + } +} + +func TestSetFlightTrackerConfigRejectsBadInput(t *testing.T) { + client := newTestClient(t, http.NewServeMux()) + + if err := client.SetFlightTrackerConfig(nil, map[string]string{"unknown": "1"}); err == nil { + t.Error("expected an error for an unknown option") + } + if err := client.SetFlightTrackerConfig(nil, map[string]string{"limit": "many"}); err == nil { + t.Error("expected an error for a non-numeric value") + } + if client.GetFlightTrackerConfig().Limit != "5000" { + t.Error("a rejected update must leave the config untouched") + } +} + +// --- assets --- + +func TestGetAirlineLogoFallsBackToTheAlternativeURL(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/assets/airlines/logotypes/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + mux.HandleFunc("/static/images/data/operators/", func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "GLO_logo0.png") { + t.Errorf("got path %q", r.URL.Path) + } + w.Header().Set("Content-Type", "image/png") + w.Write([]byte("PNG")) + }) + + logo, err := newTestClient(t, mux).GetAirlineLogo(context.Background(), "g3", "glo") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if logo == nil || string(logo.Data) != "PNG" || logo.Extension != "png" { + t.Errorf("got %+v", logo) + } +} + +func TestGetAirlineLogoReturnsNothingWhenBothURLsFail(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + }) + + logo, err := newTestClient(t, mux).GetAirlineLogo(context.Background(), "XX", "XXX") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if logo != nil { + t.Errorf("got %+v, want nothing", logo) + } +} + +func TestGetCountryFlagSlugifiesTheCountry(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/static/images/data/flags-small/", func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/myanmar-burma.svg") { + t.Errorf("got path %q", r.URL.Path) + } + if r.Header.Get("Origin") != "" { + t.Error("the origin header does not work for this request") + } + w.Header().Set("Content-Type", "image/svg+xml") + w.Write([]byte("")) + }) + + flag, err := newTestClient(t, mux).GetCountryFlag(context.Background(), "Myanmar (Burma)") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if flag == nil || flag.Extension != "svg" { + t.Errorf("got %+v", flag) + } +} + +func TestGetCountryFlagReturnsNothingForAnUnslugifiableName(t *testing.T) { + var calls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + }) + + flag, err := newTestClient(t, mux).GetCountryFlag(context.Background(), "!!!") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if flag != nil || calls.Load() != 0 { + t.Errorf("got %+v after %d calls", flag, calls.Load()) + } +} + +func TestGetCountryFlagReturnsNothingWhenMissing(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/static/images/data/flags-small/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + flag, err := newTestClient(t, mux).GetCountryFlag(context.Background(), "Atlantis") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if flag != nil { + t.Errorf("got %+v, want nothing", flag) + } +} + +// --- search and plain JSON endpoints --- + +func TestSearchGroupsResultsInTheOrderFR24CountsThem(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/search/web/find", func(w http.ResponseWriter, r *http.Request) { + if query := r.URL.Query().Get("query"); query != "Guarulhos" { + t.Errorf("got query %q", query) + } + if limit := r.URL.Query().Get("limit"); limit != "7" { + t.Errorf("got limit %q", limit) + } + w.Header().Set("Content-Type", "application/json") + + // Written as text: the key order of "count" is what lines the groups up. + w.Write([]byte(`{"results":["a1","a2","s1","o1","o2","o3"],` + + `"stats":{"count":{"airport":2,"schedule":1,"operator":3}}}`)) + }) + + groups, err := newTestClient(t, mux).Search(context.Background(), "Guarulhos", 7) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(groups["airport"]) != 2 || groups["airport"][0] != "a1" { + t.Errorf("got airports %v", groups["airport"]) + } + if len(groups["schedule"]) != 1 || groups["schedule"][0] != "s1" { + t.Errorf("got schedules %v", groups["schedule"]) + } + if len(groups["operator"]) != 3 || groups["operator"][0] != "o1" { + t.Errorf("got operators %v", groups["operator"]) + } +} + +func TestSearchSurvivesCountsPastTheResultList(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/search/web/find", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"results":["a1"],"stats":{"count":{"airport":5,"operator":2}}}`)) + }) + + groups, err := newTestClient(t, mux).Search(context.Background(), "x", 50) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(groups["airport"]) != 1 || len(groups["operator"]) != 0 { + t.Errorf("got %v", groups) + } +} + +func TestPlainJSONEndpoints(t *testing.T) { + mux := http.NewServeMux() + + for _, path := range []string{ + "/webapi/v1/airport-disruptions", "/flights/most-tracked", "/weather/volcanic", + } { + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]any{"path": r.URL.Path}) + }) + } + + client := newTestClient(t, mux) + ctx := context.Background() + + calls := map[string]func() (map[string]any, error){ + "/webapi/v1/airport-disruptions": func() (map[string]any, error) { return client.GetAirportDisruptions(ctx) }, + "/flights/most-tracked": func() (map[string]any, error) { return client.GetMostTracked(ctx) }, + "/weather/volcanic": func() (map[string]any, error) { return client.GetVolcanicEruptions(ctx) }, + } + + for path, call := range calls { + content, err := call() + + if err != nil { + t.Fatalf("%s: unexpected error: %v", path, err) + } + if content["path"] != path { + t.Errorf("got %v, want %s", content, path) + } + } +} + +// --- account --- + +func TestLoginStoresTheAccountData(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/user/login", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("got %s, want POST", r.Method) + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.PostForm.Get("email") != "user@example.com" || r.PostForm.Get("type") != "web" { + t.Errorf("got form %v", r.PostForm) + } + w.Header().Add("Set-Cookie", "_frPl=session-token; Path=/") + writeJSON(t, w, map[string]any{ + "success": true, + "userData": map[string]any{"accessToken": "token", "subscriptionKey": "key"}, + }) + }) + + client := newTestClient(t, mux) + + if client.IsLoggedIn() { + t.Error("a fresh client is not logged in") + } + if err := client.Login(context.Background(), "user@example.com", "secret"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !client.IsLoggedIn() { + t.Error("the client must be logged in") + } + + userData, err := client.GetLoginData() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if userData["accessToken"] != "token" { + t.Errorf("got %v", userData) + } + + // The getter must hand back a copy. + userData["accessToken"] = "changed" + again, _ := client.GetLoginData() + + if again["accessToken"] != "token" { + t.Error("mutating the returned data must not change the client's") + } +} + +func TestLoginReportsAFailure(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/user/login", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]any{"success": false, "message": "Your email or password is incorrect"}) + }) + + client := newTestClient(t, mux) + err := client.Login(context.Background(), "user@example.com", "wrong") + + if !errors.Is(err, ErrLogin) { + t.Fatalf("got %v, want a login error", err) + } + if !strings.Contains(err.Error(), "incorrect") { + t.Errorf("got %q, want the server's message", err) + } + if client.IsLoggedIn() { + t.Error("a failed login must not leave a session behind") + } +} + +func TestEndpointsThatNeedAnAccountRefuseWithoutOne(t *testing.T) { + client := newTestClient(t, http.NewServeMux()) + ctx := context.Background() + + if _, err := client.GetBookmarks(ctx); !errors.Is(err, ErrLogin) { + t.Errorf("GetBookmarks: got %v, want a login error", err) + } + if _, err := client.GetHistoryData(ctx, &Flight{ID: "x"}, "CSV", 0); !errors.Is(err, ErrLogin) { + t.Errorf("GetHistoryData: got %v, want a login error", err) + } + if _, err := client.GetLoginData(); !errors.Is(err, ErrLogin) { + t.Errorf("GetLoginData: got %v, want a login error", err) + } +} + +func TestLoggedInEndpointsSendTheAccessToken(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/user/login", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Set-Cookie", "_frPl=session-token; Path=/") + writeJSON(t, w, map[string]any{ + "success": true, "userData": map[string]any{"accessToken": "token"}, + }) + }) + mux.HandleFunc("/webapi/v1/bookmarks", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("accesstoken") != "token" { + t.Errorf("got access token %q", r.Header.Get("accesstoken")) + } + writeJSON(t, w, map[string]any{"bookmarks": map[string]any{}}) + }) + mux.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("file") != "csv" || r.URL.Query().Get("flight") != "2e0f1a2" { + t.Errorf("got query %v", r.URL.Query()) + } + w.Write([]byte("Timestamp,UTC,Callsign\n")) + }) + mux.HandleFunc("/common/v1/airport.json", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("token") != "session-token" { + t.Errorf("got token %q, want the session cookie", r.URL.Query().Get("token")) + } + writeJSON(t, w, map[string]any{"result": map[string]any{"response": map[string]any{ + "airport": map[string]any{"pluginData": map[string]any{"details": map[string]any{"name": "x"}}}, + }}}) + }) + + client := newTestClient(t, mux) + ctx := context.Background() + + if err := client.Login(ctx, "user@example.com", "secret"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := client.GetBookmarks(ctx); err != nil { + t.Errorf("GetBookmarks: %v", err) + } + + history, err := client.GetHistoryData(ctx, &Flight{ID: "2e0f1a2"}, "CSV", 1700000000) + + if err != nil { + t.Errorf("GetHistoryData: %v", err) + } + if !strings.HasPrefix(history, "Timestamp") { + t.Errorf("got %q", history) + } + if _, err := client.GetAirportDetails(ctx, "ATL", 10, 1); err != nil { + t.Errorf("GetAirportDetails: %v", err) + } +} + +func TestGetHistoryDataRejectsAnUnsupportedFileType(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/user/login", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]any{"success": true, "userData": map[string]any{"accessToken": "t"}}) + }) + + client := newTestClient(t, mux) + + if err := client.Login(context.Background(), "u", "p"); err != nil { + t.Fatal(err) + } + + _, err := client.GetHistoryData(context.Background(), &Flight{ID: "x"}, "PDF", 0) + + if err == nil || !strings.Contains(err.Error(), "PDF") && !strings.Contains(err.Error(), "pdf") { + t.Errorf("got %v, want an unsupported file type error", err) + } +} + +func TestLogoutClearsTheSession(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/user/login", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Set-Cookie", "_frPl=session-token; Path=/") + writeJSON(t, w, map[string]any{"success": true, "userData": map[string]any{"accessToken": "t"}}) + }) + mux.HandleFunc("/user/logout", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]any{"success": true}) + }) + + client := newTestClient(t, mux) + ctx := context.Background() + + if err := client.Login(ctx, "u", "p"); err != nil { + t.Fatal(err) + } + + loggedOut, err := client.Logout(ctx) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !loggedOut || client.IsLoggedIn() { + t.Error("the session must be gone") + } + if _, ok := client.client.getCookie("_frPl"); ok { + t.Error("the session cookie must be cleared") + } +} + +func TestLogoutWithoutASessionIsANoOp(t *testing.T) { + var calls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/user/logout", func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + }) + + loggedOut, err := newTestClient(t, mux).Logout(context.Background()) + + if err != nil || !loggedOut || calls.Load() != 0 { + t.Errorf("got %v / %v after %d calls", loggedOut, err, calls.Load()) + } +} + +func TestUnusableOptionsFallBackToTheDefault(t *testing.T) { + // Construction cannot fail, so a nonsensical value must land somewhere sane + // rather than being carried into the first request. + cases := map[string]Options{ + "zero": {}, + "negative": {Timeout: -time.Second, MaxWorkers: -4}, + } + + for name, options := range cases { + client := New(options) + + if client.Timeout != DefaultTimeout { + t.Errorf("%s: got timeout %v, want %v", name, client.Timeout, DefaultTimeout) + } + if client.MaxWorkers != defaultMaxWorkers { + t.Errorf("%s: got %d workers, want %d", name, client.MaxWorkers, defaultMaxWorkers) + } + } +} + +func TestOptionsAreOptional(t *testing.T) { + // New() with no arguments reads like the Python and Node.js constructors. + client := New() + + if client == nil || client.Timeout != DefaultTimeout { + t.Fatalf("got %+v", client) + } + if got := client.GetFlightTrackerConfig(); got.Limit != "5000" { + t.Errorf("got %+v, want the default tracker config", got) + } +} + +func TestGetAirportWithDetailsUsesTheDetailsEndpoint(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/airports/traffic-stats/", func(w http.ResponseWriter, r *http.Request) { + t.Error("the basic endpoint must not be called when details are asked for") + }) + mux.HandleFunc("/common/v1/airport.json", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]any{"result": map[string]any{"response": map[string]any{ + "airport": map[string]any{"pluginData": map[string]any{ + "details": map[string]any{ + "name": "Lukla", + "code": map[string]any{"iata": "LUA", "icao": "VNLK"}, + "position": map[string]any{ + "latitude": 27.687, "longitude": 86.729, "elevation": 9334.0, + "country": map[string]any{"name": "Nepal"}, + }, + "timezone": map[string]any{"offset": 20700.0}, + }, + "runways": []any{map[string]any{"name": "06/24"}}, + }}, + }}}) + }) + + airport, err := newTestClient(t, mux).GetAirport(context.Background(), "VNLK", true) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if airport.Name != "Lukla" || airport.ICAO != "VNLK" || airport.Country != "Nepal" { + t.Errorf("got %+v", airport) + } + if airport.Altitude == nil || *airport.Altitude != 9334 { + t.Errorf("got altitude %v", airport.Altitude) + } + if len(airport.Runways) != 1 { + t.Errorf("got runways %v", airport.Runways) + } + if airport.TimezoneOffsetHours != "5:00" { + t.Errorf("got offset hours %q, want 5:00", airport.TimezoneOffsetHours) + } +} + +func TestWithHTTPClientIsUsedForRequests(t *testing.T) { + var seen atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen.Add(1) + writeJSON(t, w, map[string]any{"ok": true}) + })) + t.Cleanup(server.Close) + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DisableCompression = true + + endpoints := testEndpoints(server.URL) + client := New(Options{HTTPClient: &http.Client{Transport: transport}, endpoints: &endpoints}) + + if _, err := client.GetMostTracked(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if seen.Load() != 1 { + t.Errorf("got %d requests through the given client, want 1", seen.Load()) + } +} + +func TestSearchRefusesANonJSONResponse(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/search/web/find", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte("Attention Required")) + }) + + _, err := newTestClient(t, mux).Search(context.Background(), "x", 50) + + if err == nil || !strings.Contains(err.Error(), "expected JSON") { + t.Errorf("got %v, want the content-type error every other endpoint gives", err) + } +} + +func TestSearchDropsAByteOrderMark(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/search/web/find", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("\xef\xbb\xbf" + `{"results":["a1"],"stats":{"count":{"airport":1}}}`)) + }) + + groups, err := newTestClient(t, mux).Search(context.Background(), "x", 50) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(groups["airport"]) != 1 { + t.Errorf("got %v", groups) + } +} + +func TestGetFlightsStillFetchesDetailsWithoutWorkers(t *testing.T) { + // MaxWorkers is public: a zero there must not turn details into a no-op. + var detailCalls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/zones/fcgi/feed.js", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, healthyFeed) + }) + mux.HandleFunc("/clickhandler/", func(w http.ResponseWriter, r *http.Request) { + detailCalls.Add(1) + writeJSON(t, w, map[string]any{"status": map[string]any{"text": "Landed"}}) + }) + + client := newTestClient(t, mux) + client.MaxWorkers = 0 + + flights, err := client.GetFlights(context.Background(), FlightSearch{Details: true}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if detailCalls.Load() != 2 { + t.Errorf("got %d detail calls, want 2", detailCalls.Load()) + } + for _, flight := range flights { + if flight.Details == nil { + t.Errorf("details missing on %s", flight.ID) + } + } +} + +func TestGetFlightsReportsAFailedDetailRequest(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/zones/fcgi/feed.js", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, healthyFeed) + }) + mux.HandleFunc("/clickhandler/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + flights, err := newTestClient(t, mux).GetFlights(context.Background(), FlightSearch{Details: true}) + + if err == nil { + t.Error("expected the detail failure to be reported") + } + if len(flights) != 2 { + t.Errorf("got %d flights, want the feed's flights returned anyway", len(flights)) + } +} + +func TestLogoutReportsAServerFailure(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/user/login", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]any{"success": true, "userData": map[string]any{"accessToken": "t"}}) + }) + mux.HandleFunc("/user/logout", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + client := newTestClient(t, mux) + + if err := client.Login(context.Background(), "u", "p"); err != nil { + t.Fatal(err) + } + + loggedOut, err := client.Logout(context.Background()) + + if err == nil { + t.Error("a failed logout must be reported, not swallowed") + } + if loggedOut { + t.Error("the server never confirmed the logout") + } + // The local session is gone either way. + if client.IsLoggedIn() { + t.Error("the local session must be cleared") + } +} + +func TestGetFlightsDoesNotRetryAHealthyFirstResponse(t *testing.T) { + var calls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/zones/fcgi/feed.js", func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Add("Set-Cookie", "AWSALB=sticky; Path=/") + writeJSON(t, w, healthyFeed) + }) + + client := newTestClient(t, mux) + flights, err := client.GetFlights(context.Background(), FlightSearch{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(flights) != 2 || calls.Load() != 1 { + t.Errorf("got %d flights after %d calls, want 2 after one", len(flights), calls.Load()) + } + // Nothing was degraded, so the stickiness must not have been shed. + if _, ok := client.client.getCookie("AWSALB"); !ok { + t.Error("the sticky cookie must survive a healthy response") + } +} + +func TestGetBoundsByPointAgreesWithThePythonPort(t *testing.T) { + // The values the Python suite pins for the same call. Compared with a + // tolerance rather than as a string: every intermediate matches bit for bit, + // but Go's math.Asin and the platform libm differ in the last place, which + // is ~1e-14 degrees — a nanometre on the ground. + const tolerance = 1e-9 + expected := []float64{52.58594974202871, 52.54997688140807, 13.253064418048115, 13.3122478541492} + + bounds := newTestClient(t, http.NewServeMux()).GetBoundsByPoint(52.567967, 13.282644, 2000) + parts := strings.Split(bounds, ",") + + if len(parts) != len(expected) { + t.Fatalf("got %q, want four values", bounds) + } + + for index, part := range parts { + got, err := strconv.ParseFloat(part, 64) + + if err != nil { + t.Fatalf("could not read %q: %v", part, err) + } + if math.Abs(got-expected[index]) > tolerance { + t.Errorf("value %d: got %v, want %v (within %v)", index, got, expected[index], tolerance) + } + } +} + +func TestGetBoundsMatchesThePythonPort(t *testing.T) { + client := newTestClient(t, http.NewServeMux()) + zone := Zone{TLY: 75.78, BRY: -75.78, TLX: -427.56, BRX: 427.56} + + if got := client.GetBounds(zone); got != "75.78,-75.78,-427.56,427.56" { + t.Errorf("got %q", got) + } +} + +func TestZeroMeansTheDefaultTheOtherPortsDeclare(t *testing.T) { + var query url.Values + mux := http.NewServeMux() + mux.HandleFunc("/common/v1/airport.json", func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + writeJSON(t, w, map[string]any{"result": map[string]any{"response": map[string]any{ + "airport": map[string]any{"pluginData": map[string]any{"details": map[string]any{"name": "x"}}}, + }}}) + }) + mux.HandleFunc("/v1/search/web/find", func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"results":[],"stats":{"count":{}}}`)) + }) + + client := newTestClient(t, mux) + + if _, err := client.GetAirportDetails(context.Background(), "ATL", 0, 0); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if query.Get("limit") != "100" || query.Get("page") != "1" { + t.Errorf("got limit=%q page=%q, want the Python defaults", query.Get("limit"), query.Get("page")) + } + + if _, err := client.Search(context.Background(), "Guarulhos", 0); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if query.Get("limit") != "50" { + t.Errorf("got limit=%q, want the Python default of 50", query.Get("limit")) + } +} + +func TestEntityConstructorsMatchTheOtherPorts(t *testing.T) { + // Airport.from_basic_info / from_info / from_details and Flight(id, info). + basic := NewAirportFromBasicInfo(map[string]any{ + "name": "Guarulhos", "iata": "GRU", "icao": "SBGR", + "lat": -23.43, "lon": -46.47, "alt": "2436", "country": "Brazil", + }) + + if basic.IATA != "GRU" || basic.Altitude == nil || *basic.Altitude != 2436 { + t.Errorf("from basic info: got %+v", basic) + } + + // One unusable coordinate drops both, as the feed parser does. + half := NewAirportFromBasicInfo(map[string]any{"lat": "n/a", "lon": -46.47}) + + if half.Latitude != nil || half.Longitude != nil { + t.Errorf("got position (%v, %v), want none", half.Latitude, half.Longitude) + } + + info := NewAirportFromInfo(map[string]any{ + "name": "Atlanta", "code": map[string]any{"iata": "ATL", "icao": "KATL"}, + }) + + if info.IATA != "ATL" || info.ICAO != "KATL" { + t.Errorf("from info: got %+v", info) + } + + fromDetails := NewAirportFromDetails(map[string]any{ + "airport": map[string]any{"pluginData": map[string]any{ + "details": map[string]any{"name": "Lukla", "code": map[string]any{"icao": "VNLK"}}, + }}, + }) + + if fromDetails.Name != "Lukla" || fromDetails.ICAO != "VNLK" { + t.Errorf("from details: got %+v", fromDetails) + } + + flight := NewFlight("2e0f1a2", flightRow) + + if flight.ID != "2e0f1a2" || flight.Registration != "PR-XYZ" { + t.Errorf("from feed row: got %+v", flight) + } +} + +func TestCallerSuppliedValuesAreEscapedIntoTheURL(t *testing.T) { + // A code or flight ID carrying "&" would otherwise cut the query short and + // FR24 would answer about something else entirely. + var query url.Values + mux := http.NewServeMux() + mux.HandleFunc("/airports/traffic-stats/", func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + writeJSON(t, w, map[string]any{"details": map[string]any{"name": "x"}}) + }) + mux.HandleFunc("/clickhandler/", func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + writeJSON(t, w, map[string]any{}) + }) + + client := newTestClient(t, mux) + + if _, err := client.GetAirport(context.Background(), "A&B", false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := query.Get("airport"); got != "A&B" { + t.Errorf("got airport=%q, want the code to survive whole", got) + } + + if _, err := client.GetFlightDetails(context.Background(), &Flight{ID: "2e0&evil=1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := query.Get("flight"); got != "2e0&evil=1" { + t.Errorf("got flight=%q, want the id to survive whole", got) + } + if query.Get("evil") != "" { + t.Error("a crafted id must not inject a query parameter") + } +} + +func TestAJarOnTheGivenClientIsIgnored(t *testing.T) { + // Two jars would append two copies of every cookie to the same header. + var received []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received = r.Header.Values("Cookie") + w.Header().Add("Set-Cookie", "_frPl=token; Path=/") + writeJSON(t, w, map[string]any{"ok": true}) + })) + t.Cleanup(server.Close) + + jar, err := cookiejar.New(nil) + + if err != nil { + t.Fatal(err) + } + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DisableCompression = true + + endpoints := testEndpoints(server.URL) + client := New(Options{ + HTTPClient: &http.Client{Transport: transport, Jar: jar}, + endpoints: &endpoints, + }) + + ctx := context.Background() + + for range 2 { + if _, err := client.GetMostTracked(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + if len(received) > 1 || strings.Count(strings.Join(received, "; "), "_frPl=") > 1 { + t.Errorf("got %v, want the cookie sent exactly once", received) + } +} diff --git a/go/flightradarapi/cookies.go b/go/flightradarapi/cookies.go new file mode 100644 index 0000000..043b1d4 --- /dev/null +++ b/go/flightradarapi/cookies.go @@ -0,0 +1,352 @@ +package flightradarapi + +import ( + "math" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +// storedCookie is one cookie in the jar, with the scope FR24 gave it. +type storedCookie struct { + name string + value string + domain string + path string + secure bool + hostOnly bool + expires time.Time + storedAt uint64 +} + +func (c *storedCookie) expired(now time.Time) bool { + return !c.expires.IsZero() && !c.expires.After(now) +} + +// cookieJar honours the scope FR24 sets on each cookie: one stored by +// www.flightradar24.com is not replayed to cdn./api./data-live., and Path, +// Secure and expiry are respected. Keyed by name/domain/path so same-named +// cookies from different hosts stay apart. +type cookieJar struct { + mu sync.Mutex + cookies map[string]*storedCookie + sequence uint64 + now func() time.Time +} + +func newCookieJar() *cookieJar { + return &cookieJar{cookies: make(map[string]*storedCookie), now: time.Now} +} + +func (j *cookieJar) clock() time.Time { + if j.now != nil { + return j.now() + } + return time.Now() +} + +// get returns the value of a stored cookie by name, ignoring scope. +func (j *cookieJar) get(name string) (string, bool) { + j.mu.Lock() + defer j.mu.Unlock() + + now := j.clock() + var match *storedCookie + + for _, cookie := range j.cookies { + if cookie.name != name || cookie.expired(now) { + continue + } + // Newest wins: a re-issued token supersedes the one it replaces. + if match == nil || cookie.storedAt > match.storedAt { + match = cookie + } + } + + if match == nil { + return "", false + } + return match.value, true +} + +// clear drops every stored cookie. +func (j *cookieJar) clear() { + j.mu.Lock() + defer j.mu.Unlock() + j.cookies = make(map[string]*storedCookie) +} + +// delete drops every cookie with this name, leaving the rest of the jar intact. +func (j *cookieJar) delete(name string) { + j.mu.Lock() + defer j.mu.Unlock() + + for key, cookie := range j.cookies { + if cookie.name == name { + delete(j.cookies, key) + } + } +} + +// store banks the Set-Cookie headers a response arrived with. +func (j *cookieJar) store(target *url.URL, headers []string) { + if target == nil { + return + } + + j.mu.Lock() + defer j.mu.Unlock() + + now := j.clock() + + for _, header := range headers { + cookie := parseSetCookie(header, target, now) + + if cookie == nil { + continue + } + if cookie.secure && target.Scheme != "https" { + continue + } + + j.sequence++ + cookie.storedAt = j.sequence + key := cookie.name + ";" + cookie.domain + ";" + cookie.path + + // An expiry in the past is a deletion instruction, not a value. + if cookie.expired(now) { + delete(j.cookies, key) + } else { + j.cookies[key] = cookie + } + } +} + +// header renders the stored cookies that are in scope for a URL. +func (j *cookieJar) header(target *url.URL) string { + selected := j.matching(target) + + if len(selected) == 0 { + return "" + } + + pairs := make([]string, 0, len(selected)) + for _, cookie := range selected { + pairs = append(pairs, cookie.name+"="+cookie.value) + } + return strings.Join(pairs, "; ") +} + +// matching returns the in-scope cookies, oldest first, one per name. +func (j *cookieJar) matching(target *url.URL) []*storedCookie { + if target == nil { + return nil + } + + j.mu.Lock() + defer j.mu.Unlock() + + now := j.clock() + secure := target.Scheme == "https" + host := strings.ToLower(target.Hostname()) + requestPath := target.Path + + if requestPath == "" { + requestPath = "/" + } + + var matches []*storedCookie + + for key, cookie := range j.cookies { + if cookie.expired(now) { + delete(j.cookies, key) + continue + } + if cookie.secure && !secure { + continue + } + if !pathMatches(requestPath, cookie.path) { + continue + } + + inScope := host == cookie.domain + if !cookie.hostOnly { + inScope = domainMatches(host, cookie.domain) + } + if inScope { + matches = append(matches, cookie) + } + } + + // Oldest first so the newest of a same-named pair wins, the rule get() + // uses. Collapsing to one per name is a deliberate limit of a flat jar. + sortByStoredAt(matches) + + seen := make(map[string]int, len(matches)) + deduped := make([]*storedCookie, 0, len(matches)) + + for _, cookie := range matches { + if index, ok := seen[cookie.name]; ok { + deduped[index] = cookie + continue + } + seen[cookie.name] = len(deduped) + deduped = append(deduped, cookie) + } + return deduped +} + +func sortByStoredAt(cookies []*storedCookie) { + for i := 1; i < len(cookies); i++ { + for j := i; j > 0 && cookies[j-1].storedAt > cookies[j].storedAt; j-- { + cookies[j-1], cookies[j] = cookies[j], cookies[j-1] + } + } +} + +// parseSetCookie parses one Set-Cookie header into a cookie record, or nil when +// it is unusable. Hand-rolled rather than delegated to net/http, which widens a +// relative Path to "/" and accepts a Domain the response host does not cover. +// +// now is the jar's clock, so "Max-Age=0" resolves to an expiry the caller reads +// as the deletion it is. +func parseSetCookie(header string, target *url.URL, now time.Time) *storedCookie { + parts := strings.Split(header, ";") + pair := parts[0] + separator := strings.Index(pair, "=") + + // Split on the first "=" only: session tokens are routinely base64 and end + // in "=" padding, which a greedy split truncates. + if separator < 1 { + return nil + } + + name := strings.TrimSpace(pair[:separator]) + + if name == "" { + return nil + } + + host := strings.ToLower(target.Hostname()) + cookie := &storedCookie{ + name: name, + value: strings.TrimSpace(pair[separator+1:]), + domain: host, + path: defaultPath(target.Path), + hostOnly: true, + } + + for _, part := range parts[1:] { + index := strings.Index(part, "=") + key := part + value := "" + + if index >= 0 { + key, value = part[:index], strings.TrimSpace(part[index+1:]) + } + key = strings.ToLower(strings.TrimSpace(key)) + + switch key { + case "secure": + cookie.secure = true + case "path": + if strings.HasPrefix(value, "/") { + cookie.path = value + } + case "expires": + if cookie.expires.IsZero() { + if parsed, err := parseCookieTime(value); err == nil { + cookie.expires = parsed + } + } + case "max-age": + // Max-Age wins over Expires, and <= 0 means "delete now". A + // malformed value must be ignored, not read as 0. + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil { + cookie.expires = now.Add(secondsToDuration(seconds)) + } + case "domain": + if value == "" { + continue + } + domain := strings.ToLower(strings.TrimPrefix(value, ".")) + + // A dotless domain is a TLD: Domain=com would scope the cookie to + // every .com host requested later. + if strings.Contains(domain, ".") && domainMatches(host, domain) { + cookie.domain = domain + cookie.hostOnly = false + } else { + // RFC 6265 5.3.6: a Domain the host is not under discards the + // cookie rather than narrowing it back to the host. + return nil + } + } + } + return cookie +} + +// maxCookieSeconds is the longest Max-Age a Duration can carry, about 292 years. +const maxCookieSeconds = int64(math.MaxInt64) / int64(time.Second) + +// secondsToDuration converts a Max-Age, clamping instead of overflowing: the +// wrap-around turns a very long-lived cookie into an expiry in the past, which +// the jar would read as an instruction to delete it. +func secondsToDuration(seconds int64) time.Duration { + return time.Duration(min(max(seconds, -maxCookieSeconds), maxCookieSeconds)) * time.Second +} + +// parseCookieTime accepts the date formats a cookie Expires arrives in. +func parseCookieTime(value string) (time.Time, error) { + if parsed, err := http.ParseTime(value); err == nil { + return parsed, nil + } + + layouts := []string{ + "Mon, 02-Jan-2006 15:04:05 MST", + "Mon, 02 Jan 2006 15:04:05 -0700", + "Mon Jan 02 2006 15:04:05 MST", + } + var err error + + for _, layout := range layouts { + var parsed time.Time + if parsed, err = time.Parse(layout, value); err == nil { + return parsed, nil + } + } + return time.Time{}, err +} + +// defaultPath is the RFC 6265 default-path: the request path up to, but not +// including, the rightmost "/". +func defaultPath(pathname string) string { + if !strings.HasPrefix(pathname, "/") { + return "/" + } + lastSlash := strings.LastIndex(pathname, "/") + + if lastSlash < 1 { + return "/" + } + return pathname[:lastSlash] +} + +// domainMatches reports whether a cookie domain covers a host. +func domainMatches(host, domain string) bool { + return host == domain || strings.HasSuffix(host, "."+domain) +} + +// pathMatches reports whether a cookie path covers a request path. +func pathMatches(requestPath, cookiePath string) bool { + if requestPath == cookiePath { + return true + } + if !strings.HasPrefix(requestPath, cookiePath) { + return false + } + return strings.HasSuffix(cookiePath, "/") || requestPath[len(cookiePath)] == '/' +} diff --git a/go/flightradarapi/cookies_test.go b/go/flightradarapi/cookies_test.go new file mode 100644 index 0000000..788dd5b --- /dev/null +++ b/go/flightradarapi/cookies_test.go @@ -0,0 +1,252 @@ +package flightradarapi + +import ( + "net/url" + "strings" + "testing" + "time" +) + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + parsed, err := url.Parse(raw) + + if err != nil { + t.Fatalf("could not parse %q: %v", raw, err) + } + return parsed +} + +func TestJarKeepsCookiesInTheScopeFR24SetThem(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/user/login"), []string{"_frPl=token; Path=/"}) + + if header := jar.header(mustURL(t, "https://www.flightradar24.com/data/airlines")); header != "_frPl=token" { + t.Errorf("got %q, want the cookie to be sent to its own host", header) + } + if header := jar.header(mustURL(t, "https://cdn.flightradar24.com/assets/x.png")); header != "" { + t.Errorf("got %q, want no cookie for another host", header) + } +} + +func TestJarHonoursADomainAttribute(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/"), + []string{"shared=1; Domain=.flightradar24.com; Path=/"}) + + for _, target := range []string{ + "https://cdn.flightradar24.com/x", "https://data-live.flightradar24.com/y", + } { + if header := jar.header(mustURL(t, target)); header != "shared=1" { + t.Errorf("%s: got %q, want shared=1", target, header) + } + } +} + +func TestJarRejectsADomainTheHostIsNotUnder(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{ + "evil=1; Domain=example.com", + // A dotless domain is a TLD: it would leak to every .com host. + "tld=1; Domain=com", + }) + + if header := jar.header(mustURL(t, "https://www.flightradar24.com/")); header != "" { + t.Errorf("got %q, want the cookies to be discarded", header) + } +} + +func TestJarKeepsTheDefaultPathOfTheRequest(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/webapi/v1/bookmarks"), []string{"scoped=1"}) + + if header := jar.header(mustURL(t, "https://www.flightradar24.com/webapi/v1/other")); header != "scoped=1" { + t.Errorf("got %q, want the cookie inside its default path", header) + } + if header := jar.header(mustURL(t, "https://www.flightradar24.com/data/airlines")); header != "" { + t.Errorf("got %q, want no cookie outside its default path", header) + } +} + +func TestJarKeepsBase64PaddingInAValue(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"token=YWJjZA==; Path=/"}) + + if value, _ := jar.get("token"); value != "YWJjZA==" { + t.Errorf("got %q, want YWJjZA==", value) + } +} + +func TestJarTreatsAnExpiryInThePastAsADeletion(t *testing.T) { + jar := newCookieJar() + target := mustURL(t, "https://www.flightradar24.com/") + + jar.store(target, []string{"session=1; Path=/"}) + jar.store(target, []string{"session=1; Path=/; Max-Age=-1"}) + + if _, ok := jar.get("session"); ok { + t.Error("a negative Max-Age must delete the cookie") + } +} + +func TestJarIgnoresAMalformedMaxAge(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"session=1; Path=/; Max-Age=soon"}) + + if _, ok := jar.get("session"); !ok { + t.Error("a malformed Max-Age must be ignored, not read as an expiry") + } +} + +func TestJarDropsAnExpiredCookieOnRead(t *testing.T) { + now := time.Now() + jar := newCookieJar() + jar.now = func() time.Time { return now } + jar.store(mustURL(t, "https://www.flightradar24.com/"), + []string{"session=1; Path=/; Expires=Wed, 21 Oct 2015 07:28:00 GMT"}) + + if _, ok := jar.get("session"); ok { + t.Error("an expired cookie must not be stored") + } +} + +func TestJarSkipsASecureCookieOverPlainHTTP(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"secure=1; Path=/; Secure"}) + + if header := jar.header(mustURL(t, "http://www.flightradar24.com/")); header != "" { + t.Errorf("got %q, want no secure cookie over http", header) + } +} + +func TestJarNewestValueOfANameWins(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/data/airlines"), []string{"token=old"}) + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"token=new; Path=/"}) + + if value, _ := jar.get("token"); value != "new" { + t.Errorf("got %q, want new", value) + } + if header := jar.header(mustURL(t, "https://www.flightradar24.com/data/airlines")); header != "token=new" { + t.Errorf("got %q, want token=new", header) + } +} + +func TestJarDeleteDropsEveryScopeOfAName(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/zones/fcgi"), []string{"AWSALB=sticky"}) + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"AWSALB=sticky2; Path=/", "_frPl=token; Path=/"}) + + jar.delete("AWSALB") + + if _, ok := jar.get("AWSALB"); ok { + t.Error("delete must drop every scope of the name") + } + if _, ok := jar.get("_frPl"); !ok { + t.Error("delete must leave the rest of the jar intact") + } +} + +func TestJarClearDropsEverything(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"a=1; Path=/", "b=2; Path=/"}) + jar.clear() + + if header := jar.header(mustURL(t, "https://www.flightradar24.com/")); header != "" { + t.Errorf("got %q, want an empty jar", header) + } +} + +func TestJarIgnoresAnUnparsableHeader(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"", "=value", "novalue", "; Path=/"}) + + if header := jar.header(mustURL(t, "https://www.flightradar24.com/")); header != "" { + t.Errorf("got %q, want nothing stored", header) + } +} + +func TestPathMatchesFollowsRFC6265(t *testing.T) { + cases := []struct { + requestPath string + cookiePath string + expected bool + }{ + {"/webapi/v1", "/webapi/v1", true}, + {"/webapi/v1/bookmarks", "/webapi/v1", true}, + {"/webapi/v12", "/webapi/v1", false}, + {"/webapi", "/webapi/v1", false}, + {"/anything", "/", true}, + } + + for _, testCase := range cases { + if got := pathMatches(testCase.requestPath, testCase.cookiePath); got != testCase.expected { + t.Errorf("pathMatches(%q, %q) = %v, want %v", + testCase.requestPath, testCase.cookiePath, got, testCase.expected) + } + } +} + +func TestJarTreatsMaxAgeZeroAsADeletion(t *testing.T) { + now := time.Now() + jar := newCookieJar() + jar.now = func() time.Time { return now } + target := mustURL(t, "https://www.flightradar24.com/") + + jar.store(target, []string{"session=1; Path=/"}) + jar.store(target, []string{"session=1; Path=/; Max-Age=0"}) + + if _, ok := jar.get("session"); ok { + t.Error("Max-Age=0 must delete the cookie") + } + if len(jar.cookies) != 0 { + t.Errorf("got %d stored cookies, want the entry dropped", len(jar.cookies)) + } +} + +func TestJarMaxAgeUsesTheInjectedClock(t *testing.T) { + frozen := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + jar := newCookieJar() + jar.now = func() time.Time { return frozen } + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"session=1; Path=/; Max-Age=60"}) + + stored := jar.cookies["session;www.flightradar24.com;/"] + + if stored == nil { + t.Fatal("the cookie was not stored") + } + if !stored.expires.Equal(frozen.Add(time.Minute)) { + t.Errorf("got expiry %v, want it measured from the jar's clock", stored.expires) + } +} + +func TestJarDeleteOfAnAbsentCookieIsANoOp(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"_frPl=token; Path=/"}) + + jar.delete("AWSALB") + + if _, ok := jar.get("_frPl"); !ok { + t.Error("deleting an absent cookie must leave the jar intact") + } +} + +func TestJarClampsAnEnormousMaxAge(t *testing.T) { + // The nanosecond conversion used to wrap, turning a very long-lived cookie + // into an expiry in the past — read as a deletion. + now := time.Now() + + for _, maxAge := range []string{"9300000000", "99999999999999", "-99999999999999"} { + jar := newCookieJar() + jar.now = func() time.Time { return now } + jar.store(mustURL(t, "https://www.flightradar24.com/"), + []string{"session=1; Path=/; Max-Age=" + maxAge}) + + _, stored := jar.get("session") + wantStored := !strings.HasPrefix(maxAge, "-") + + if stored != wantStored { + t.Errorf("Max-Age=%s: stored=%v, want %v", maxAge, stored, wantStored) + } + } +} diff --git a/go/flightradarapi/core.go b/go/flightradarapi/core.go new file mode 100644 index 0000000..08ba314 --- /dev/null +++ b/go/flightradarapi/core.go @@ -0,0 +1,158 @@ +package flightradarapi + +import ( + "fmt" + "maps" + "net/url" +) + +// Base URLs of the FlightRadar24 services this package talks to. +const ( + apiFlightRadarBaseURL = "https://api.flightradar24.com/common/v1" + cdnFlightRadarBaseURL = "https://cdn.flightradar24.com" + flightRadarBaseURL = "https://www.flightradar24.com" + dataLiveBaseURL = "https://data-live.flightradar24.com" + dataCloudBaseURL = "https://data-cloud.flightradar24.com" +) + +// endpoints holds every URL the client requests. Grouped in a struct so tests +// can point the whole surface at a local server. +type endpoints struct { + userLogin string + userLogout string + + searchData string + + realTimeFlightTrackerData string + flightData string + + historicalData string + + apiAirportData string + airportData string + airportsJSON string + airportDisruption string + + airlinesData string + + volcanicEruptionData string + mostTracked string + bookmarks string + + countryFlag string + airlineLogo string + alternativeAirlineLogo string +} + +func defaultEndpoints() endpoints { + return endpoints{ + userLogin: flightRadarBaseURL + "/user/login", + userLogout: flightRadarBaseURL + "/user/logout", + + searchData: flightRadarBaseURL + "/v1/search/web/find?query=%s&limit=%d", + + realTimeFlightTrackerData: dataCloudBaseURL + "/zones/fcgi/feed.js", + flightData: dataLiveBaseURL + "/clickhandler/?flight=%s", + + historicalData: flightRadarBaseURL + "/download/?flight=%s&file=%s&trailLimit=0&history=%d", + + apiAirportData: apiFlightRadarBaseURL + "/airport.json", + airportData: flightRadarBaseURL + "/airports/traffic-stats/?airport=%s", + airportsJSON: flightRadarBaseURL + "/_json/airports.php", + airportDisruption: flightRadarBaseURL + "/webapi/v1/airport-disruptions", + + airlinesData: flightRadarBaseURL + "/data/airlines", + + volcanicEruptionData: flightRadarBaseURL + "/weather/volcanic", + mostTracked: flightRadarBaseURL + "/flights/most-tracked", + bookmarks: flightRadarBaseURL + "/webapi/v1/bookmarks", + + countryFlag: flightRadarBaseURL + "/static/images/data/flags-small/%s.svg", + airlineLogo: cdnFlightRadarBaseURL + "/assets/airlines/logotypes/%s_%s.png", + alternativeAirlineLogo: flightRadarBaseURL + "/static/images/data/operators/%s_logo0.png", + } +} + +func (e endpoints) searchDataURL(query string, limit int) string { + return fmt.Sprintf(e.searchData, url.QueryEscape(query), limit) +} + +// Every caller-supplied value is escaped for the position it lands in: an +// airport code or flight ID carrying "&" would otherwise cut the query short and +// FR24 would answer about something else entirely. +func (e endpoints) flightDataURL(flightID string) string { + return fmt.Sprintf(e.flightData, url.QueryEscape(flightID)) +} + +func (e endpoints) historicalDataURL(flightID, fileType string, timestamp int64) string { + return fmt.Sprintf(e.historicalData, url.QueryEscape(flightID), url.QueryEscape(fileType), timestamp) +} + +func (e endpoints) airportDataURL(code string) string { + return fmt.Sprintf(e.airportData, url.QueryEscape(code)) +} + +// The slug is already reduced to [a-z0-9-], so this escape is belt and braces. +func (e endpoints) countryFlagURL(slug string) string { + return fmt.Sprintf(e.countryFlag, url.PathEscape(slug)) +} + +func (e endpoints) airlineLogoURL(iata, icao string) string { + return fmt.Sprintf(e.airlineLogo, url.PathEscape(iata), url.PathEscape(icao)) +} + +func (e endpoints) alternativeAirlineLogoURL(icao string) string { + return fmt.Sprintf(e.alternativeAirlineLogo, url.PathEscape(icao)) +} + +const chromeUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + + " (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" + +// baseHeaders are the browser-shaped headers sent on every API request. +var baseHeaders = map[string]string{ + "accept-language": "en-US,en;q=0.9", + "cache-control": "max-age=0", + "origin": flightRadarBaseURL, + "referer": flightRadarBaseURL + "/", + "sec-ch-ua": `"Google Chrome";v="136", "Chromium";v="136", "Not-A.Brand";v="24"`, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": `"Windows"`, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-site", + "user-agent": chromeUserAgent, +} + +// jsonHeaders, imageHeaders and htmlHeaders mirror what Chrome sends for each +// kind of resource. +var ( + jsonHeaders = withHeaders(baseHeaders, map[string]string{"accept": "application/json"}) + imageHeaders = withHeaders(baseHeaders, map[string]string{ + "accept": "image/gif, image/jpg, image/jpeg, image/png", + }) + htmlHeaders = map[string]string{ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9," + + "image/avif,image/webp,image/apng,*/*;q=0.8," + + "application/signed-exchange;v=b3;q=0.7", + "accept-language": "en-US,en;q=0.9", + "cache-control": "max-age=0", + "referer": flightRadarBaseURL + "/", + "sec-ch-ua": `"Google Chrome";v="136", "Chromium";v="136", "Not-A.Brand";v="24"`, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": `"Windows"`, + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + "user-agent": chromeUserAgent, + } +) + +// withHeaders returns base merged with extra, leaving both untouched. +func withHeaders(base, extra map[string]string) map[string]string { + merged := make(map[string]string, len(base)+len(extra)) + maps.Copy(merged, base) + maps.Copy(merged, extra) + return merged +} diff --git a/go/flightradarapi/countries.go b/go/flightradarapi/countries.go new file mode 100644 index 0000000..0d3231b --- /dev/null +++ b/go/flightradarapi/countries.go @@ -0,0 +1,484 @@ +// Code generated from python/FlightRadarAPI/core.py. DO NOT EDIT. +// +// TestCountryConstantsMatchThePythonEnum fails when the Python enum moves ahead +// of this file, which is the signal to regenerate it. + +package flightradarapi + +import "slices" + +// Country is a FlightRadar24 country slug, accepted by [Client.GetAirports], and +// the counterpart of the Countries enum in the Python and Node.js SDKs: what +// they spell Countries.BRAZIL is CountryBrazil here. +// +// Any spelling works: values are slugified before matching, so +// Country("Myanmar (Burma)") is the same filter as CountryMyanmarBurma. +type Country string + +// Country slugs, as FlightRadar24 spells them in its data page URLs. +const ( + CountryAfghanistan Country = "afghanistan" + CountryAlbania Country = "albania" + CountryAlgeria Country = "algeria" + CountryAmericanSamoa Country = "american-samoa" + CountryAngola Country = "angola" + CountryAnguilla Country = "anguilla" + CountryAntarctica Country = "antarctica" + CountryAntiguaAndBarbuda Country = "antigua-and-barbuda" + CountryArgentina Country = "argentina" + CountryArmenia Country = "armenia" + CountryAruba Country = "aruba" + CountryAustralia Country = "australia" + CountryAustria Country = "austria" + CountryAzerbaijan Country = "azerbaijan" + CountryBahamas Country = "bahamas" + CountryBahrain Country = "bahrain" + CountryBangladesh Country = "bangladesh" + CountryBarbados Country = "barbados" + CountryBelarus Country = "belarus" + CountryBelgium Country = "belgium" + CountryBelize Country = "belize" + CountryBenin Country = "benin" + CountryBermuda Country = "bermuda" + CountryBhutan Country = "bhutan" + CountryBolivia Country = "bolivia" + CountryBosniaAndHerzegovina Country = "bosnia-and-herzegovina" + CountryBotswana Country = "botswana" + CountryBrazil Country = "brazil" + CountryBrunei Country = "brunei" + CountryBulgaria Country = "bulgaria" + CountryBurkinaFaso Country = "burkina-faso" + CountryBurundi Country = "burundi" + CountryCambodia Country = "cambodia" + CountryCameroon Country = "cameroon" + CountryCanada Country = "canada" + CountryCapeVerde Country = "cape-verde" + CountryCaymanIslands Country = "cayman-islands" + CountryCentralAfricanRepublic Country = "central-african-republic" + CountryChad Country = "chad" + CountryChile Country = "chile" + CountryChina Country = "china" + CountryCocosKeelingIslands Country = "cocos-keeling-islands" + CountryColombia Country = "colombia" + CountryComoros Country = "comoros" + CountryCongo Country = "congo" + CountryCookIslands Country = "cook-islands" + CountryCostaRica Country = "costa-rica" + CountryCroatia Country = "croatia" + CountryCuba Country = "cuba" + CountryCuracao Country = "curacao" + CountryCyprus Country = "cyprus" + CountryCzechia Country = "czechia" + CountryDemocraticRepublicOfTheCongo Country = "democratic-republic-of-the-congo" + CountryDenmark Country = "denmark" + CountryDjibouti Country = "djibouti" + CountryDominica Country = "dominica" + CountryDominicanRepublic Country = "dominican-republic" + CountryEcuador Country = "ecuador" + CountryEgypt Country = "egypt" + CountryElSalvador Country = "el-salvador" + CountryEquatorialGuinea Country = "equatorial-guinea" + CountryEritrea Country = "eritrea" + CountryEstonia Country = "estonia" + CountryEswatini Country = "eswatini" + CountryEthiopia Country = "ethiopia" + CountryFalklandIslandsMalvinas Country = "falkland-islands-malvinas" + CountryFaroeIslands Country = "faroe-islands" + CountryFiji Country = "fiji" + CountryFinland Country = "finland" + CountryFrance Country = "france" + CountryFrenchGuiana Country = "french-guiana" + CountryFrenchPolynesia Country = "french-polynesia" + CountryGabon Country = "gabon" + CountryGambia Country = "gambia" + CountryGeorgia Country = "georgia" + CountryGermany Country = "germany" + CountryGhana Country = "ghana" + CountryGibraltar Country = "gibraltar" + CountryGreece Country = "greece" + CountryGreenland Country = "greenland" + CountryGrenada Country = "grenada" + CountryGuadeloupe Country = "guadeloupe" + CountryGuam Country = "guam" + CountryGuatemala Country = "guatemala" + CountryGuernsey Country = "guernsey" + CountryGuinea Country = "guinea" + CountryGuineaBissau Country = "guinea-bissau" + CountryGuyana Country = "guyana" + CountryHaiti Country = "haiti" + CountryHonduras Country = "honduras" + CountryHongKong Country = "hong-kong" + CountryHungary Country = "hungary" + CountryIceland Country = "iceland" + CountryIndia Country = "india" + CountryIndonesia Country = "indonesia" + CountryIran Country = "iran" + CountryIraq Country = "iraq" + CountryIreland Country = "ireland" + CountryIsleOfMan Country = "isle-of-man" + CountryIsrael Country = "israel" + CountryItaly Country = "italy" + CountryIvoryCoast Country = "ivory-coast" + CountryJamaica Country = "jamaica" + CountryJapan Country = "japan" + CountryJersey Country = "jersey" + CountryJordan Country = "jordan" + CountryKazakhstan Country = "kazakhstan" + CountryKenya Country = "kenya" + CountryKiribati Country = "kiribati" + CountryKosovo Country = "kosovo" + CountryKuwait Country = "kuwait" + CountryKyrgyzstan Country = "kyrgyzstan" + CountryLaos Country = "laos" + CountryLatvia Country = "latvia" + CountryLebanon Country = "lebanon" + CountryLesotho Country = "lesotho" + CountryLiberia Country = "liberia" + CountryLibya Country = "libya" + CountryLithuania Country = "lithuania" + CountryLuxembourg Country = "luxembourg" + CountryMacao Country = "macao" + CountryMadagascar Country = "madagascar" + CountryMalawi Country = "malawi" + CountryMalaysia Country = "malaysia" + CountryMaldives Country = "maldives" + CountryMali Country = "mali" + CountryMalta Country = "malta" + CountryMarshallIslands Country = "marshall-islands" + CountryMartinique Country = "martinique" + CountryMauritania Country = "mauritania" + CountryMauritius Country = "mauritius" + CountryMayotte Country = "mayotte" + CountryMexico Country = "mexico" + CountryMicronesia Country = "micronesia" + CountryMoldova Country = "moldova" + CountryMonaco Country = "monaco" + CountryMongolia Country = "mongolia" + CountryMontenegro Country = "montenegro" + CountryMontserrat Country = "montserrat" + CountryMorocco Country = "morocco" + CountryMozambique Country = "mozambique" + CountryMyanmarBurma Country = "myanmar-burma" + CountryNamibia Country = "namibia" + CountryNauru Country = "nauru" + CountryNepal Country = "nepal" + CountryNetherlands Country = "netherlands" + CountryNewCaledonia Country = "new-caledonia" + CountryNewZealand Country = "new-zealand" + CountryNicaragua Country = "nicaragua" + CountryNiger Country = "niger" + CountryNigeria Country = "nigeria" + CountryNorthKorea Country = "north-korea" + CountryNorthMacedonia Country = "north-macedonia" + CountryNorthernMarianaIslands Country = "northern-mariana-islands" + CountryNorway Country = "norway" + CountryOman Country = "oman" + CountryPakistan Country = "pakistan" + CountryPalau Country = "palau" + CountryPanama Country = "panama" + CountryPapuaNewGuinea Country = "papua-new-guinea" + CountryParaguay Country = "paraguay" + CountryPeru Country = "peru" + CountryPhilippines Country = "philippines" + CountryPoland Country = "poland" + CountryPortugal Country = "portugal" + CountryPuertoRico Country = "puerto-rico" + CountryQatar Country = "qatar" + CountryReunion Country = "reunion" + CountryRomania Country = "romania" + CountryRussia Country = "russia" + CountryRwanda Country = "rwanda" + CountrySaintHelena Country = "saint-helena" + CountrySaintKittsAndNevis Country = "saint-kitts-and-nevis" + CountrySaintLucia Country = "saint-lucia" + CountrySaintPierreAndMiquelon Country = "saint-pierre-and-miquelon" + CountrySaintVincentAndTheGrenadines Country = "saint-vincent-and-the-grenadines" + CountrySamoa Country = "samoa" + CountrySaoTomeAndPrincipe Country = "sao-tome-and-principe" + CountrySaudiArabia Country = "saudi-arabia" + CountrySenegal Country = "senegal" + CountrySerbia Country = "serbia" + CountrySeychelles Country = "seychelles" + CountrySierraLeone Country = "sierra-leone" + CountrySingapore Country = "singapore" + CountrySlovakia Country = "slovakia" + CountrySlovenia Country = "slovenia" + CountrySolomonIslands Country = "solomon-islands" + CountrySomalia Country = "somalia" + CountrySouthAfrica Country = "south-africa" + CountrySouthKorea Country = "south-korea" + CountrySouthSudan Country = "south-sudan" + CountrySpain Country = "spain" + CountrySriLanka Country = "sri-lanka" + CountrySudan Country = "sudan" + CountrySuriname Country = "suriname" + CountrySweden Country = "sweden" + CountrySwitzerland Country = "switzerland" + CountrySyria Country = "syria" + CountryTaiwan Country = "taiwan" + CountryTajikistan Country = "tajikistan" + CountryTanzania Country = "tanzania" + CountryThailand Country = "thailand" + CountryTimorLesteEastTimor Country = "timor-leste-east-timor" + CountryTogo Country = "togo" + CountryTonga Country = "tonga" + CountryTrinidadAndTobago Country = "trinidad-and-tobago" + CountryTunisia Country = "tunisia" + CountryTurkey Country = "turkey" + CountryTurkmenistan Country = "turkmenistan" + CountryTurksAndCaicosIslands Country = "turks-and-caicos-islands" + CountryTuvalu Country = "tuvalu" + CountryUganda Country = "uganda" + CountryUkraine Country = "ukraine" + CountryUnitedArabEmirates Country = "united-arab-emirates" + CountryUnitedKingdom Country = "united-kingdom" + CountryUnitedStates Country = "united-states" + CountryUnitedStatesMinorOutlyingIslands Country = "united-states-minor-outlying-islands" + CountryUruguay Country = "uruguay" + CountryUzbekistan Country = "uzbekistan" + CountryVanuatu Country = "vanuatu" + CountryVenezuela Country = "venezuela" + CountryVietnam Country = "vietnam" + CountryVirginIslandsBritish Country = "virgin-islands-british" + CountryVirginIslandsUs Country = "virgin-islands-us" + CountryWallisAndFutuna Country = "wallis-and-futuna" + CountryYemen Country = "yemen" + CountryZambia Country = "zambia" + CountryZimbabwe Country = "zimbabwe" +) + +// AllCountries returns every country above, in the order FR24 declares them — +// what list(Countries) gives in the Python port. The slice is a fresh copy, so +// a caller sorting or filtering it cannot corrupt the package's own data. +func AllCountries() []Country { return slices.Clone(allCountries) } + +var allCountries = []Country{ + CountryAfghanistan, + CountryAlbania, + CountryAlgeria, + CountryAmericanSamoa, + CountryAngola, + CountryAnguilla, + CountryAntarctica, + CountryAntiguaAndBarbuda, + CountryArgentina, + CountryArmenia, + CountryAruba, + CountryAustralia, + CountryAustria, + CountryAzerbaijan, + CountryBahamas, + CountryBahrain, + CountryBangladesh, + CountryBarbados, + CountryBelarus, + CountryBelgium, + CountryBelize, + CountryBenin, + CountryBermuda, + CountryBhutan, + CountryBolivia, + CountryBosniaAndHerzegovina, + CountryBotswana, + CountryBrazil, + CountryBrunei, + CountryBulgaria, + CountryBurkinaFaso, + CountryBurundi, + CountryCambodia, + CountryCameroon, + CountryCanada, + CountryCapeVerde, + CountryCaymanIslands, + CountryCentralAfricanRepublic, + CountryChad, + CountryChile, + CountryChina, + CountryCocosKeelingIslands, + CountryColombia, + CountryComoros, + CountryCongo, + CountryCookIslands, + CountryCostaRica, + CountryCroatia, + CountryCuba, + CountryCuracao, + CountryCyprus, + CountryCzechia, + CountryDemocraticRepublicOfTheCongo, + CountryDenmark, + CountryDjibouti, + CountryDominica, + CountryDominicanRepublic, + CountryEcuador, + CountryEgypt, + CountryElSalvador, + CountryEquatorialGuinea, + CountryEritrea, + CountryEstonia, + CountryEswatini, + CountryEthiopia, + CountryFalklandIslandsMalvinas, + CountryFaroeIslands, + CountryFiji, + CountryFinland, + CountryFrance, + CountryFrenchGuiana, + CountryFrenchPolynesia, + CountryGabon, + CountryGambia, + CountryGeorgia, + CountryGermany, + CountryGhana, + CountryGibraltar, + CountryGreece, + CountryGreenland, + CountryGrenada, + CountryGuadeloupe, + CountryGuam, + CountryGuatemala, + CountryGuernsey, + CountryGuinea, + CountryGuineaBissau, + CountryGuyana, + CountryHaiti, + CountryHonduras, + CountryHongKong, + CountryHungary, + CountryIceland, + CountryIndia, + CountryIndonesia, + CountryIran, + CountryIraq, + CountryIreland, + CountryIsleOfMan, + CountryIsrael, + CountryItaly, + CountryIvoryCoast, + CountryJamaica, + CountryJapan, + CountryJersey, + CountryJordan, + CountryKazakhstan, + CountryKenya, + CountryKiribati, + CountryKosovo, + CountryKuwait, + CountryKyrgyzstan, + CountryLaos, + CountryLatvia, + CountryLebanon, + CountryLesotho, + CountryLiberia, + CountryLibya, + CountryLithuania, + CountryLuxembourg, + CountryMacao, + CountryMadagascar, + CountryMalawi, + CountryMalaysia, + CountryMaldives, + CountryMali, + CountryMalta, + CountryMarshallIslands, + CountryMartinique, + CountryMauritania, + CountryMauritius, + CountryMayotte, + CountryMexico, + CountryMicronesia, + CountryMoldova, + CountryMonaco, + CountryMongolia, + CountryMontenegro, + CountryMontserrat, + CountryMorocco, + CountryMozambique, + CountryMyanmarBurma, + CountryNamibia, + CountryNauru, + CountryNepal, + CountryNetherlands, + CountryNewCaledonia, + CountryNewZealand, + CountryNicaragua, + CountryNiger, + CountryNigeria, + CountryNorthKorea, + CountryNorthMacedonia, + CountryNorthernMarianaIslands, + CountryNorway, + CountryOman, + CountryPakistan, + CountryPalau, + CountryPanama, + CountryPapuaNewGuinea, + CountryParaguay, + CountryPeru, + CountryPhilippines, + CountryPoland, + CountryPortugal, + CountryPuertoRico, + CountryQatar, + CountryReunion, + CountryRomania, + CountryRussia, + CountryRwanda, + CountrySaintHelena, + CountrySaintKittsAndNevis, + CountrySaintLucia, + CountrySaintPierreAndMiquelon, + CountrySaintVincentAndTheGrenadines, + CountrySamoa, + CountrySaoTomeAndPrincipe, + CountrySaudiArabia, + CountrySenegal, + CountrySerbia, + CountrySeychelles, + CountrySierraLeone, + CountrySingapore, + CountrySlovakia, + CountrySlovenia, + CountrySolomonIslands, + CountrySomalia, + CountrySouthAfrica, + CountrySouthKorea, + CountrySouthSudan, + CountrySpain, + CountrySriLanka, + CountrySudan, + CountrySuriname, + CountrySweden, + CountrySwitzerland, + CountrySyria, + CountryTaiwan, + CountryTajikistan, + CountryTanzania, + CountryThailand, + CountryTimorLesteEastTimor, + CountryTogo, + CountryTonga, + CountryTrinidadAndTobago, + CountryTunisia, + CountryTurkey, + CountryTurkmenistan, + CountryTurksAndCaicosIslands, + CountryTuvalu, + CountryUganda, + CountryUkraine, + CountryUnitedArabEmirates, + CountryUnitedKingdom, + CountryUnitedStates, + CountryUnitedStatesMinorOutlyingIslands, + CountryUruguay, + CountryUzbekistan, + CountryVanuatu, + CountryVenezuela, + CountryVietnam, + CountryVirginIslandsBritish, + CountryVirginIslandsUs, + CountryWallisAndFutuna, + CountryYemen, + CountryZambia, + CountryZimbabwe, +} diff --git a/go/flightradarapi/doc.go b/go/flightradarapi/doc.go new file mode 100644 index 0000000..0b2e3ef --- /dev/null +++ b/go/flightradarapi/doc.go @@ -0,0 +1,39 @@ +// Package flightradarapi is an unofficial SDK for FlightRadar24. +// +// It provides the flight and airport data available to the public on the +// FlightRadar24 website. Start with [New], then call the methods of [Client]. +// +// See more information at: +// +// https://www.flightradar24.com/premium/ +// https://www.flightradar24.com/terms-and-conditions +// +// # Porting from the Python and Node.js SDKs +// +// The three SDKs carry the same features; the names differ only where Go's +// conventions do. The package name is part of every identifier here, so the +// client is [Client] rather than FlightRadar24API, the way it is http.Client and +// not http.HTTPClient. +// +// Python / Node.js This package +// ------------------------ -------------------------------------------- +// FlightRadar24API() New(), returning a *Client +// FlightRadar24API({...}) New(Options{...}) +// FlightRadarError ErrFlightRadar, wrapped by every error here +// Countries.BRAZIL CountryBrazil, with AllCountries() to enumerate +// get_flights(airline, ...) Client.GetFlights(ctx, FlightSearch{...}) +// check_info(min_altitude=x) Flight.CheckInfo(map[string]any{"min_altitude": x}) +// Airport.from_details(x) NewAirportFromDetails(x) +// (bytes, extension) tuple *Image +// airline["n_aircrafts"] Airline.NumAircrafts +// flight.destination_... flight.Details.Destination... +// +// Every method of FlightRadar24API has a counterpart here with the same +// parameters, and a test reads the Python source to keep it that way. +package flightradarapi + +// Version of this package. +const Version = "1.6.0" + +// Author of this package. +const Author = "Jean Loui Bernard Silva de Jesus" diff --git a/go/flightradarapi/entities_test.go b/go/flightradarapi/entities_test.go new file mode 100644 index 0000000..1ed4e7f --- /dev/null +++ b/go/flightradarapi/entities_test.go @@ -0,0 +1,571 @@ +package flightradarapi + +import ( + "math" + "testing" +) + +func TestGetDistanceFromMeasuresBetweenEntities(t *testing.T) { + gru := newAirportFromBasicInfo(basicAirportInfo{ + Name: "Guarulhos", IATA: "GRU", ICAO: "SBGR", + Latitude: float64Ptr(-23.429991), Longitude: float64Ptr(-46.4674), + }) + gig := newAirportFromBasicInfo(basicAirportInfo{ + Name: "Galeao", IATA: "GIG", ICAO: "SBGL", + Latitude: float64Ptr(-22.805696), Longitude: float64Ptr(-43.25523), + }) + + distance, err := gru.GetDistanceFrom(gig) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Roughly 340 km between the two airports. + if math.Abs(distance-340) > 10 { + t.Errorf("got %.1f km, want about 340 km", distance) + } +} + +func TestGetDistanceFromIsZeroForTheSamePoint(t *testing.T) { + airport := newAirportFromBasicInfo(basicAirportInfo{ + Latitude: float64Ptr(10), Longitude: float64Ptr(20), + }) + distance, err := airport.GetDistanceFrom(airport) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if distance != 0 { + t.Errorf("got %v km, want 0", distance) + } +} + +func TestGetDistanceFromRefusesAnEntityWithNoPosition(t *testing.T) { + positioned := newAirportFromBasicInfo(basicAirportInfo{ + Latitude: float64Ptr(10), Longitude: float64Ptr(20), + }) + unpositioned := newAirportFromBasicInfo(basicAirportInfo{}) + + if _, err := positioned.GetDistanceFrom(unpositioned); err == nil { + t.Error("expected an error for an entity with no position") + } + if _, err := unpositioned.GetDistanceFrom(positioned); err == nil { + t.Error("expected an error for an entity with no position") + } +} + +func TestAirportStringShowsCodeNameAndPosition(t *testing.T) { + airport := newAirportFromBasicInfo(basicAirportInfo{ + Name: "Guarulhos", ICAO: "SBGR", Altitude: float64Ptr(2436), + Latitude: float64Ptr(-23.43), Longitude: float64Ptr(-46.47), + }) + want := "<(SBGR) Guarulhos - Altitude: 2436 - Latitude: -23.43 - Longitude: -46.47>" + + if got := airport.String(); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestNewAirportFromInfoReadsTheDetailsBlock(t *testing.T) { + airport := newAirportFromInfo(map[string]any{ + "name": "Hartsfield Jackson Atlanta International Airport", + "code": map[string]any{"iata": "ATL", "icao": "KATL"}, + "position": map[string]any{ + "latitude": 33.6367, "longitude": -84.428101, "altitude": 1026.0, + "country": map[string]any{"name": "United States", "code": "US"}, + "region": map[string]any{"city": "Atlanta"}, + }, + "timezone": map[string]any{ + "name": "America/New_York", "offset": -14400.0, "offsetHours": "-4:00", + "abbr": "EDT", "abbrName": "Eastern Daylight Time", + }, + "visible": true, + "website": "http://www.atlanta-airport.com", + }) + + if airport.Name == "" || airport.IATA != "ATL" || airport.ICAO != "KATL" { + t.Errorf("got %q / %q / %q", airport.Name, airport.IATA, airport.ICAO) + } + if airport.Latitude == nil || *airport.Latitude != 33.6367 { + t.Errorf("got latitude %v", airport.Latitude) + } + if airport.Country != "United States" || airport.CountryCode != "US" || airport.City != "Atlanta" { + t.Errorf("got %q / %q / %q", airport.Country, airport.CountryCode, airport.City) + } + if airport.TimezoneName != "America/New_York" || airport.TimezoneAbbr != "EDT" { + t.Errorf("got %q / %q", airport.TimezoneName, airport.TimezoneAbbr) + } + if airport.Visible == nil || !*airport.Visible { + t.Errorf("got visible %v", airport.Visible) + } +} + +func TestSetAirportDetailsFillsTheAirportIn(t *testing.T) { + airport := NewAirport() + airport.SetAirportDetails(map[string]any{ + "airport": map[string]any{"pluginData": map[string]any{ + "details": map[string]any{ + "name": "Guarulhos", + "code": map[string]any{"iata": "GRU", "icao": "SBGR"}, + "position": map[string]any{ + "latitude": -23.43, "longitude": -46.47, "elevation": 2436.0, + "country": map[string]any{"name": "Brazil", "code": "BR", "id": 30.0}, + "region": map[string]any{"city": "Sao Paulo"}, + }, + "timezone": map[string]any{"offset": -10800.0, "name": "America/Sao_Paulo"}, + "url": map[string]any{"homepage": "https://gru.com.br", "wikipedia": "https://wiki"}, + "visible": true, + }, + "flightdiary": map[string]any{ + "url": "/airports/reviews/gru", "reviews": 12.0, "evaluation": 80.0, + "ratings": map[string]any{"avg": 4.5, "total": 30.0}, + }, + "schedule": map[string]any{"arrivals": map[string]any{"total": 10.0}}, + "aircraftCount": map[string]any{"onGround": map[string]any{"total": 40.0, "visible": 35.0}}, + "runways": []any{map[string]any{"name": "09R/27L"}}, + "weather": map[string]any{"temp": map[string]any{"celsius": 21.0}}, + }}, + }) + + if airport.Name != "Guarulhos" || airport.IATA != "GRU" || airport.ICAO != "SBGR" { + t.Errorf("got %q / %q / %q", airport.Name, airport.IATA, airport.ICAO) + } + if airport.Altitude == nil || *airport.Altitude != 2436 { + t.Errorf("got altitude %v", airport.Altitude) + } + if airport.TimezoneOffsetHours != "-3:00" { + t.Errorf("got offset hours %q, want -3:00", airport.TimezoneOffsetHours) + } + if airport.ReviewsURL != "https://www.flightradar24.com/airports/reviews/gru" { + t.Errorf("got reviews URL %q", airport.ReviewsURL) + } + if airport.AverageRating == nil || *airport.AverageRating != 4.5 { + t.Errorf("got average rating %v", airport.AverageRating) + } + if airport.AircraftOnGround == nil || *airport.AircraftOnGround != 40 { + t.Errorf("got aircraft on ground %v", airport.AircraftOnGround) + } + if len(airport.Runways) != 1 || len(airport.Arrivals) != 1 || len(airport.Weather) != 1 { + t.Errorf("got runways %v arrivals %v weather %v", + airport.Runways, airport.Arrivals, airport.Weather) + } + if airport.Wikipedia != "https://wiki" || airport.Website != "https://gru.com.br" { + t.Errorf("got %q / %q", airport.Wikipedia, airport.Website) + } +} + +func TestSetAirportDetailsSurvivesAnEmptyPayload(t *testing.T) { + airport := NewAirport() + airport.SetAirportDetails(map[string]any{}) + + if airport.Name != "" || airport.Latitude != nil || airport.TimezoneOffsetHours != "" { + t.Errorf("got %q / %v / %q", airport.Name, airport.Latitude, airport.TimezoneOffsetHours) + } +} + +// feedRow is a live-feed flight entry, in the order the feed sends its fields. +func feedRow() []any { + return []any{ + "2D6E1C7", 43.1234, -8.4567, 270.0, 36000.0, 480.0, "1234", nil, + "B738", "PR-GUP", 1.7e9, "GRU", "GIG", "G31234", 0.0, -64.0, "GLO1234", nil, "GLO", + } +} + +func TestNewFlightReadsTheFeedRow(t *testing.T) { + flight := newFlight("2e0f1a2", feedRow()) + + if flight.ID != "2e0f1a2" || flight.ICAO24Bit != "2D6E1C7" { + t.Errorf("got %q / %q", flight.ID, flight.ICAO24Bit) + } + if flight.Latitude == nil || *flight.Latitude != 43.1234 { + t.Errorf("got latitude %v", flight.Latitude) + } + if flight.AircraftCode != "B738" || flight.Registration != "PR-GUP" { + t.Errorf("got %q / %q", flight.AircraftCode, flight.Registration) + } + if flight.Number != "G31234" || flight.AirlineIATA != "G3" || flight.AirlineICAO != "GLO" { + t.Errorf("got %q / %q / %q", flight.Number, flight.AirlineIATA, flight.AirlineICAO) + } + if flight.Time == nil || *flight.Time != 1700000000 { + t.Errorf("got time %v", flight.Time) + } + if flight.Callsign != "GLO1234" || flight.Squawk != "1234" { + t.Errorf("got %q / %q", flight.Callsign, flight.Squawk) + } +} + +func TestNewFlightSurvivesAShortRow(t *testing.T) { + flight := newFlight("abc", []any{"2D6E1C7", 10.0}) + + if flight.Latitude == nil || *flight.Latitude != 10 { + t.Errorf("got latitude %v", flight.Latitude) + } + if flight.Longitude != nil || flight.Altitude != nil || flight.Number != "" { + t.Errorf("got %v / %v / %q", flight.Longitude, flight.Altitude, flight.Number) + } +} + +func TestFlightFormatters(t *testing.T) { + flight := newFlight("x", feedRow()) + + cases := map[string]struct{ got, want string }{ + "altitude": {flight.GetAltitude(), "36000 ft"}, + "flight level": {flight.GetFlightLevel(), "360 FL"}, + "ground speed": {flight.GetGroundSpeed(), "480 kts"}, + "heading": {flight.GetHeading(), "270°"}, + "vertical speed": {flight.GetVerticalSpeed(), "-64 fpm"}, + } + + for name, testCase := range cases { + if testCase.got != testCase.want { + t.Errorf("%s: got %q, want %q", name, testCase.got, testCase.want) + } + } +} + +func TestFlightFormattersFallBackToTheDefaultText(t *testing.T) { + flight := newFlight("x", []any{"2D6E1C7"}) + + for name, got := range map[string]string{ + "altitude": flight.GetAltitude(), + "flight level": flight.GetFlightLevel(), + "ground speed": flight.GetGroundSpeed(), + "heading": flight.GetHeading(), + "vertical speed": flight.GetVerticalSpeed(), + } { + if got != DefaultText { + t.Errorf("%s: got %q, want %q", name, got, DefaultText) + } + } +} + +func TestFlightLevelBelowTenThousandFeetIsTheAltitude(t *testing.T) { + row := feedRow() + row[fieldAltitude] = 9000.0 + + if got := newFlight("x", row).GetFlightLevel(); got != "9000 ft" { + t.Errorf("got %q, want 9000 ft", got) + } +} + +func TestGroundSpeedIsSingularAtOneKnot(t *testing.T) { + row := feedRow() + row[fieldGroundSpeed] = 1.0 + + if got := newFlight("x", row).GetGroundSpeed(); got != "1 kt" { + t.Errorf("got %q, want 1 kt", got) + } +} + +func TestCheckInfoComparesEqualityAndBounds(t *testing.T) { + flight := newFlight("x", feedRow()) + + cases := []struct { + name string + criteria map[string]any + expected bool + }{ + {"equal string", map[string]any{"airline_icao": "GLO"}, true}, + {"different string", map[string]any{"airline_icao": "THY"}, false}, + {"equal number", map[string]any{"altitude": 36000}, true}, + {"min met", map[string]any{"min_altitude": 6700}, true}, + {"min not met", map[string]any{"min_altitude": 40000}, false}, + {"max met", map[string]any{"max_altitude": 40000}, true}, + {"max not met", map[string]any{"max_altitude": 10000}, false}, + {"range met", map[string]any{"min_altitude": 6700, "max_altitude": 40000}, true}, + {"several criteria", map[string]any{"min_altitude": 6700, "airline_icao": "GLO"}, true}, + {"one criterion fails", map[string]any{"min_altitude": 6700, "airline_icao": "THY"}, false}, + {"bound at the value", map[string]any{"min_altitude": 36000, "max_altitude": 36000}, true}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + matched, err := flight.CheckInfo(testCase.criteria) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if matched != testCase.expected { + t.Errorf("got %v, want %v", matched, testCase.expected) + } + }) + } +} + +func TestCheckInfoRejectsAnUnknownField(t *testing.T) { + flight := newFlight("x", feedRow()) + + if _, err := flight.CheckInfo(map[string]any{"min_speed": 100}); err == nil { + t.Error("expected an error for an unknown field") + } +} + +func TestCheckInfoDoesNotMatchAMissingValue(t *testing.T) { + flight := newFlight("x", []any{"2D6E1C7"}) + matched, err := flight.CheckInfo(map[string]any{"min_altitude": 100}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if matched { + t.Error("a flight with no altitude must not match min_altitude") + } +} + +func TestSetFlightDetailsFillsDetailsIn(t *testing.T) { + flight := newFlight("x", feedRow()) + flight.SetFlightDetails(map[string]any{ + "aircraft": map[string]any{ + "age": "10 years", "countryId": 30.0, + "model": map[string]any{"text": "Boeing 737-800"}, + "images": map[string]any{"thumbnails": []any{}}, + }, + "airline": map[string]any{"name": "Gol Linhas Aereas", "short": "Gol"}, + "airport": map[string]any{ + "origin": map[string]any{ + "name": "Guarulhos", "website": "https://gru.com.br", "visible": true, + "code": map[string]any{"icao": "SBGR"}, + "info": map[string]any{"gate": "12", "terminal": "2", "baggage": "5"}, + "position": map[string]any{"latitude": -23.43, "longitude": -46.47, "altitude": 2436.0, + "country": map[string]any{"name": "Brazil", "code": "BR"}}, + "timezone": map[string]any{"abbr": "BRT", "name": "America/Sao_Paulo", "offset": -10800.0}, + }, + "destination": map[string]any{ + "name": "Galeao", + "code": map[string]any{"icao": "SBGL"}, + "info": map[string]any{"gate": nil, "terminal": "N/A"}, + }, + }, + "flightHistory": map[string]any{"aircraft": []any{map[string]any{"flight": "G31233"}}}, + "status": map[string]any{"icon": "green", "text": "Estimated 12:00"}, + "time": map[string]any{"scheduled": map[string]any{"departure": 1.7e9}}, + "trail": []any{map[string]any{"lat": -23.4, "lng": -46.4}}, + }) + + details := flight.Details + + if details == nil { + t.Fatal("details were not set") + } + if details.AircraftModel != "Boeing 737-800" || details.AircraftAge != "10 years" { + t.Errorf("got %q / %q", details.AircraftModel, details.AircraftAge) + } + if details.AirlineName != "Gol Linhas Aereas" || details.AirlineShortName != "Gol" { + t.Errorf("got %q / %q", details.AirlineName, details.AirlineShortName) + } + if details.OriginAirportICAO != "SBGR" || details.OriginAirportGate != "12" { + t.Errorf("got %q / %q", details.OriginAirportICAO, details.OriginAirportGate) + } + if details.OriginAirportCountryName != "Brazil" || details.OriginAirportTimezoneAbbr != "BRT" { + t.Errorf("got %q / %q", details.OriginAirportCountryName, details.OriginAirportTimezoneAbbr) + } + if details.DestinationAirportICAO != "SBGL" || details.DestinationAirportName != "Galeao" { + t.Errorf("got %q / %q", details.DestinationAirportICAO, details.DestinationAirportName) + } + + // Both a null and the literal "N/A" mean the feed sent nothing. + if details.DestinationAirportGate != "" || details.DestinationAirportTerminal != "" { + t.Errorf("got %q / %q, want empty strings", + details.DestinationAirportGate, details.DestinationAirportTerminal) + } + if details.StatusText != "Estimated 12:00" || details.StatusIcon != "green" { + t.Errorf("got %q / %q", details.StatusText, details.StatusIcon) + } + if len(details.Trail) != 1 || len(details.AircraftHistory) != 1 || len(details.TimeDetails) != 1 { + t.Errorf("got trail %v history %v time %v", + details.Trail, details.AircraftHistory, details.TimeDetails) + } + if details.OriginAirportVisible == nil || !*details.OriginAirportVisible { + t.Errorf("got visible %v", details.OriginAirportVisible) + } +} + +func TestFlightStringShowsTheKeyValues(t *testing.T) { + flight := newFlight("x", feedRow()) + want := "<(B738) PR-GUP - Altitude: 36000 - Ground Speed: 480 - Heading: 270>" + + if got := flight.String(); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestGetDistanceFromRefusesATypedNilEntity(t *testing.T) { + // A nil *Flight in a non-nil interface used to panic here. + airport := newAirportFromBasicInfo(basicAirportInfo{ + Latitude: float64Ptr(1), Longitude: float64Ptr(2), + }) + + for name, other := range map[string]Positioned{ + "nil interface": nil, + "nil flight": (*Flight)(nil), + "nil airport": (*Airport)(nil), + } { + if _, err := airport.GetDistanceFrom(other); err == nil { + t.Errorf("%s: expected an error, got none", name) + } + } +} + +func TestCheckInfoReachesTheDetailFields(t *testing.T) { + // The Python and Node.js ports match against the flight's own attributes, + // which include everything set_flight_details wrote. + flight := newFlight("x", feedRow()) + flight.SetFlightDetails(map[string]any{ + "airline": map[string]any{"name": "Gol Linhas Aereas"}, + "airport": map[string]any{ + "origin": map[string]any{ + "name": "Guarulhos", + "position": map[string]any{"altitude": 2436.0}, + }, + }, + "status": map[string]any{"text": "Landed"}, + }) + + cases := []struct { + name string + criteria map[string]any + expected bool + }{ + {"detail string match", map[string]any{"airline_name": "Gol Linhas Aereas"}, true}, + {"detail string mismatch", map[string]any{"airline_name": "Turkish Airlines"}, false}, + {"detail number bound", map[string]any{"min_origin_airport_altitude": 1000}, true}, + {"detail number bound not met", map[string]any{"max_origin_airport_altitude": 1000}, false}, + {"detail and feed together", map[string]any{ + "status_text": "Landed", "airline_icao": "GLO", "min_altitude": 1000, + }, true}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + matched, err := flight.CheckInfo(testCase.criteria) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if matched != testCase.expected { + t.Errorf("got %v, want %v", matched, testCase.expected) + } + }) + } +} + +func TestCheckInfoOnlyReachesDetailsOnceTheyAreSet(t *testing.T) { + flight := newFlight("x", feedRow()) + + if _, err := flight.CheckInfo(map[string]any{"airline_name": "Gol"}); err == nil { + t.Error("expected an error before SetFlightDetails") + } + + flight.SetFlightDetails(map[string]any{}) + + if _, err := flight.CheckInfo(map[string]any{"airline_name": "Gol"}); err != nil { + t.Errorf("unexpected error after SetFlightDetails: %v", err) + } +} + +func TestCheckInfoDoesNotPanicOnAnUncomparableValue(t *testing.T) { + // A detail value can be a slice, which "==" panics on. + flight := newFlight("x", feedRow()) + flight.SetFlightDetails(map[string]any{"trail": []any{map[string]any{"lat": 1.0}}}) + + matched, err := flight.CheckInfo(map[string]any{"trail": []any{}}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if matched { + t.Error("an empty trail does not equal a trail with one point") + } + + if matched, err = flight.CheckInfo(map[string]any{ + "trail": []any{map[string]any{"lat": 1.0}}, + }); err != nil || !matched { + t.Errorf("got %v / %v, want an equal trail to match", matched, err) + } +} + +func TestSnakeCaseKeepsAcronymsWhole(t *testing.T) { + cases := map[string]string{ + "AircraftAge": "aircraft_age", + "AircraftCountryID": "aircraft_country_id", + "DestinationAirportICAO": "destination_airport_icao", + "OriginAirportTimezoneOffsetHours": "origin_airport_timezone_offset_hours", + "StatusText": "status_text", + "Trail": "trail", + } + + for field, expected := range cases { + if got := snakeCase(field); got != expected { + t.Errorf("%s: got %q, want %q", field, got, expected) + } + } +} + +func TestCheckInfoComparesTextAsText(t *testing.T) { + // The Python and Node.js ports compare with != and !==, so a squawk of + // "0417" is not the squawk "417". + flight := newFlight("x", feedRow()) + flight.Squawk = "0417" + + cases := map[string]struct { + criteria map[string]any + expected bool + }{ + "same text": {map[string]any{"squawk": "0417"}, true}, + "same number as text": {map[string]any{"squawk": "417"}, false}, + "registration is text": {map[string]any{"registration": "PR-GUP"}, true}, + } + + for name, testCase := range cases { + matched, err := flight.CheckInfo(testCase.criteria) + + if err != nil { + t.Fatalf("%s: unexpected error: %v", name, err) + } + if matched != testCase.expected { + t.Errorf("%s: got %v, want %v", name, matched, testCase.expected) + } + } +} + +func TestCheckInfoAcceptsGoNumericTypes(t *testing.T) { + flight := newFlight("x", feedRow()) + + for name, criteria := range map[string]map[string]any{ + "int": {"altitude": 36000}, + "int64": {"altitude": int64(36000)}, + "float32": {"altitude": float32(36000)}, + "float64": {"altitude": 36000.0}, + "bounds": {"min_altitude": 6700, "max_altitude": int64(40000)}, + } { + matched, err := flight.CheckInfo(criteria) + + if err != nil || !matched { + t.Errorf("%s: got %v (err=%v), want a match", name, matched, err) + } + } +} + +func TestConstructorsReadGoNumericTypes(t *testing.T) { + // The exported constructors take a hand-built map, which carries Go's own + // numeric types rather than the float64 a JSON decoder produces. + airport := NewAirportFromBasicInfo(map[string]any{ + "name": "X", "iata": "XXX", "lat": 40, "lon": -73, "alt": int64(100), + }) + + if airport.Latitude == nil || *airport.Latitude != 40 { + t.Errorf("got latitude %v, want 40", airport.Latitude) + } + if airport.Longitude == nil || *airport.Longitude != -73 { + t.Errorf("got longitude %v, want -73", airport.Longitude) + } + if airport.Altitude == nil || *airport.Altitude != 100 { + t.Errorf("got altitude %v, want 100", airport.Altitude) + } + + // A bool is not a number, in any of the three ports. + if number := toNumber(true); number != nil { + t.Errorf("got %v for a bool, want nil", *number) + } +} diff --git a/go/flightradarapi/entity.go b/go/flightradarapi/entity.go new file mode 100644 index 0000000..78a0051 --- /dev/null +++ b/go/flightradarapi/entity.go @@ -0,0 +1,69 @@ +package flightradarapi + +import ( + "fmt" + "math" + "reflect" +) + +const earthRadiusKM = 6371 + +func radians(degrees float64) float64 { return degrees * (math.Pi / 180) } + +func degrees(radians float64) float64 { return radians * (180 / math.Pi) } + +// Positioned is anything with a location, so distances can be measured between +// an [Airport] and a [Flight]. +type Positioned interface { + Position() (latitude, longitude *float64) +} + +// Entity is a real entity at some location. Both [Airport] and [Flight] embed it. +type Entity struct { + // Latitude and Longitude are nil when the feed sent no usable position. + Latitude *float64 + Longitude *float64 +} + +// Position implements [Positioned]. +func (e Entity) Position() (*float64, *float64) { return e.Latitude, e.Longitude } + +func (e *Entity) setPosition(latitude, longitude *float64) { + e.Latitude, e.Longitude = latitude, longitude +} + +// GetDistanceFrom returns the distance from another entity, in kilometers. +func (e Entity) GetDistanceFrom(other Positioned) (float64, error) { + // A nil *Flight in a non-nil interface is still nil: calling Position() on + // it would panic where the caller expects this error. + if other == nil || isNil(other) { + return 0, fmt.Errorf("%w: cannot calculate distance: no other entity given", ErrFlightRadar) + } + + otherLatitude, otherLongitude := other.Position() + + if e.Latitude == nil || e.Longitude == nil || otherLatitude == nil || otherLongitude == nil { + return 0, fmt.Errorf("%w: cannot calculate distance: one or both entities have no position", + ErrFlightRadar) + } + + lat1, lon1 := radians(*e.Latitude), radians(*e.Longitude) + lat2, lon2 := radians(*otherLatitude), radians(*otherLongitude) + + cosine := math.Sin(lat1)*math.Sin(lat2) + math.Cos(lat1)*math.Cos(lat2)*math.Cos(lon2-lon1) + + // Rounding can push the cosine just outside [-1, 1], where Acos is NaN. + return math.Acos(math.Min(1, math.Max(-1, cosine))) * earthRadiusKM, nil +} + +// isNil reports whether a non-nil interface holds a nil value. +func isNil(value any) bool { + reflected := reflect.ValueOf(value) + + switch reflected.Kind() { + case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan: + return reflected.IsNil() + default: + return false + } +} diff --git a/go/flightradarapi/errors.go b/go/flightradarapi/errors.go new file mode 100644 index 0000000..fd8cdb5 --- /dev/null +++ b/go/flightradarapi/errors.go @@ -0,0 +1,82 @@ +package flightradarapi + +import ( + "errors" + "fmt" + "net/http" +) + +// Sentinels for the package's error taxonomy. Every error returned by this +// package wraps ErrFlightRadar, so errors.Is(err, ErrFlightRadar) matches all of +// them — the counterpart of catching the FlightRadarError base class in the +// Python and Node.js SDKs. +var ( + ErrFlightRadar = errors.New("flightradar24") + ErrAirportNotFound = fmt.Errorf("%w: airport not found", ErrFlightRadar) + ErrCloudflare = fmt.Errorf("%w: blocked by cloudflare", ErrFlightRadar) + ErrDecompressionLimit = fmt.Errorf("%w: response body past the size limit", ErrFlightRadar) + ErrLogin = fmt.Errorf("%w: login", ErrFlightRadar) +) + +// AirportNotFoundError reports a code no airport answered to. +type AirportNotFoundError struct { + Code string + Message string + // Errors carries the FR24 validation payload, when the API returned one. + Errors map[string]any +} + +func (e *AirportNotFoundError) Error() string { + if e.Message != "" { + return e.Message + } + return fmt.Sprintf("could not find an airport by the code %q", e.Code) +} + +func (e *AirportNotFoundError) Unwrap() error { return ErrAirportNotFound } + +// CloudflareError reports a block by Cloudflare rather than by the FR24 origin. +type CloudflareError struct { + Message string + // Response is the blocked response, with Body already drained. + Response *http.Response + // Body is the challenge page, kept readable after the drain. + Body []byte +} + +func (e *CloudflareError) Error() string { return e.Message } + +func (e *CloudflareError) Unwrap() error { return ErrCloudflare } + +// DecompressionLimitError reports a body that grew past the size budget. +type DecompressionLimitError struct { + Message string +} + +func (e *DecompressionLimitError) Error() string { return e.Message } + +func (e *DecompressionLimitError) Unwrap() error { return ErrDecompressionLimit } + +// LoginError reports a failed login, or an authenticated endpoint reached +// without one. +type LoginError struct { + Message string +} + +func (e *LoginError) Error() string { return e.Message } + +func (e *LoginError) Unwrap() error { return ErrLogin } + +// StatusError reports a status code the caller did not allow. +type StatusError struct { + StatusCode int + Status string + URL string + Body []byte +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("received status code %q for the URL %s", e.Status, e.URL) +} + +func (e *StatusError) Unwrap() error { return ErrFlightRadar } diff --git a/go/flightradarapi/example_test.go b/go/flightradarapi/example_test.go new file mode 100644 index 0000000..70e9021 --- /dev/null +++ b/go/flightradarapi/example_test.go @@ -0,0 +1,183 @@ +// This file is compiled as an outside consumer of the package, so it also keeps +// the public surface honest: anything an example needs has to be exported. +package flightradarapi_test + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi" +) + +// The examples that reach FlightRadar24 carry no "Output" comment, so `go test` +// compiles them without running them. + +func Example() { + client := flightradarapi.New() + + flights, err := client.GetFlights(context.Background(), flightradarapi.FlightSearch{}) + + if err != nil { + log.Fatal(err) + } + + for _, flight := range flights[:min(5, len(flights))] { + fmt.Println(flight.Callsign, flight.GetFlightLevel(), flight.GetGroundSpeed()) + } +} + +func ExampleClient_GetFlights_abovePosition() { + client := flightradarapi.New() + + // Your point is 52°34'04.7"N 13°16'57.5"E from Google Maps, and a radius of + // 2 km around it. + bounds := client.GetBoundsByPoint(52.567774, 13.282827, 2000) + + flights, err := client.GetFlights(context.Background(), flightradarapi.FlightSearch{ + Bounds: bounds, + }) + + if err != nil { + log.Fatal(err) + } + fmt.Println(len(flights), "flights overhead") +} + +func ExampleClient_GetFlights_withDetails() { + client := flightradarapi.New(flightradarapi.Options{MaxWorkers: 4}) + + // One extra request per flight, four at a time. + flights, err := client.GetFlights(context.Background(), flightradarapi.FlightSearch{ + Airline: "GLO", + Details: true, + }) + + if err != nil { + log.Fatal(err) + } + + for _, flight := range flights { + fmt.Println(flight.Callsign, "→", flight.Details.DestinationAirportName) + } +} + +func ExampleClient_GetAirports() { + client := flightradarapi.New() + + // Pass nil for every airport in the feed. + airports, err := client.GetAirports(context.Background(), []flightradarapi.Country{ + flightradarapi.CountryBrazil, + flightradarapi.CountryUnitedStates, + }) + + if err != nil { + log.Fatal(err) + } + fmt.Println(len(airports), "airports") +} + +func ExampleClient_GetAirport_details() { + client := flightradarapi.New(flightradarapi.Options{Timeout: 10 * time.Second}) + + airport, err := client.GetAirport(context.Background(), "VNLK", true) + + if errors.Is(err, flightradarapi.ErrAirportNotFound) { + fmt.Println("no such airport") + return + } + if err != nil { + log.Fatal(err) + } + fmt.Println(airport.Name, airport.TimezoneName, len(airport.Runways)) +} + +func ExampleClient_Login() { + client := flightradarapi.New() + + if err := client.Login(context.Background(), "email", "password"); err != nil { + log.Fatal(err) + } + defer client.Logout(context.Background()) + + // Downloading history data needs a premium account. + data, err := client.GetHistoryData(context.Background(), &flightradarapi.Flight{ID: "2e0f1a2"}, + "CSV", time.Now().Unix()) + + if err != nil { + log.Fatal(err) + } + fmt.Println(len(data), "bytes of history") +} + +func ExampleNewRetryPolicy() { + // Three attempts, with exponential backoff between them. + retry, err := flightradarapi.NewRetryPolicy(3) + + if err != nil { + log.Fatal(err) + } + + client := flightradarapi.New(flightradarapi.Options{Retry: retry}) + + _, err = client.GetMostTracked(context.Background()) + + // Every error of this package wraps a sentinel. + if errors.Is(err, flightradarapi.ErrCloudflare) { + fmt.Println("still blocked after three attempts") + } +} + +func ExampleClient_GetBounds() { + client := flightradarapi.New() + + // The zones are bundled, so this needs no request. + fmt.Println(client.GetBounds(client.GetZones()["europe"])) + // Output: 72.57,33.57,-16.96,53.05 +} + +func ExampleFlight_CheckInfo() { + altitude, groundSpeed := 12000.0, 430.0 + flight := &flightradarapi.Flight{ + Callsign: "THY1", + AirlineICAO: "THY", + Altitude: &altitude, + GroundSpeed: &groundSpeed, + } + + // "min_" and "max_" compare numerically; anything else compares for equality. + matched, err := flight.CheckInfo(map[string]any{ + "min_altitude": 6700, + "max_altitude": 13000, + "airline_icao": "THY", + }) + + if err != nil { + log.Fatal(err) + } + fmt.Println(matched, flight.GetFlightLevel()) + // Output: true 120 FL +} + +func ExampleEntity_GetDistanceFrom() { + latitude, longitude := -23.43, -46.47 + airport := &flightradarapi.Airport{ + IATA: "GRU", + Entity: flightradarapi.Entity{Latitude: &latitude, Longitude: &longitude}, + } + + flightLatitude, flightLongitude := -22.81, -43.25 + flight := &flightradarapi.Flight{ + Entity: flightradarapi.Entity{Latitude: &flightLatitude, Longitude: &flightLongitude}, + } + + distance, err := airport.GetDistanceFrom(flight) + + if err != nil { + log.Fatal(err) + } + fmt.Printf("%.0f km\n", distance) + // Output: 336 km +} diff --git a/go/flightradarapi/flight.go b/go/flightradarapi/flight.go new file mode 100644 index 0000000..0bfa180 --- /dev/null +++ b/go/flightradarapi/flight.go @@ -0,0 +1,495 @@ +package flightradarapi + +import ( + "fmt" + "reflect" + "strings" + "sync" + "unicode" +) + +// Indices of the live feed's flight array. 7 and 17 are unused. +const ( + fieldICAO24Bit = iota + fieldLatitude + fieldLongitude + fieldHeading + fieldAltitude + fieldGroundSpeed + fieldSquawk + _ + fieldAircraftCode + fieldRegistration + fieldTime + fieldOriginIATA + fieldDestinationIATA + fieldFlightNumber + fieldOnGround + fieldVerticalSpeed + fieldCallsign + _ + fieldAirlineICAO +) + +// Flight is a flight from the Real Time Flight Tracker. Details holds the extra +// information [Client.GetFlightDetails] returns, once SetFlightDetails is called. +type Flight struct { + Entity + + ID string + ICAO24Bit string + // Heading, Altitude, GroundSpeed and VerticalSpeed are nil when the feed + // sent no value. + Heading *float64 + Altitude *float64 + GroundSpeed *float64 + Squawk string + AircraftCode string + Registration string + Time *int64 + OriginAirportIATA string + DestinationAirportIATA string + Number string + AirlineIATA string + // OnGround is 1 while the aircraft is on the ground. + OnGround *float64 + VerticalSpeed *float64 + Callsign string + AirlineICAO string + + Details *FlightDetails +} + +// FlightDetails is the extra information carried by a flight details payload. +type FlightDetails struct { + AircraftAge string + AircraftCountryID *float64 + AircraftHistory []any + AircraftImages any + AircraftModel string + + AirlineName string + AirlineShortName string + + DestinationAirportAltitude *float64 + DestinationAirportCountryCode string + DestinationAirportCountryName string + DestinationAirportLatitude *float64 + DestinationAirportLongitude *float64 + DestinationAirportICAO string + DestinationAirportBaggage string + DestinationAirportGate string + DestinationAirportName string + DestinationAirportTerminal string + DestinationAirportVisible *bool + DestinationAirportWebsite string + DestinationAirportTimezoneAbbr string + DestinationAirportTimezoneAbbrName string + DestinationAirportTimezoneName string + DestinationAirportTimezoneOffset *float64 + DestinationAirportTimezoneOffsetHours string + + OriginAirportAltitude *float64 + OriginAirportCountryCode string + OriginAirportCountryName string + OriginAirportLatitude *float64 + OriginAirportLongitude *float64 + OriginAirportICAO string + OriginAirportBaggage string + OriginAirportGate string + OriginAirportName string + OriginAirportTerminal string + OriginAirportVisible *bool + OriginAirportWebsite string + OriginAirportTimezoneAbbr string + OriginAirportTimezoneAbbrName string + OriginAirportTimezoneName string + OriginAirportTimezoneOffset *float64 + OriginAirportTimezoneOffsetHours string + + StatusIcon string + StatusText string + + TimeDetails map[string]any + Trail []any + + // Raw is the payload these fields came from. + Raw map[string]any +} + +// NewFlight builds a flight from one entry of the live feed, the counterpart of +// the Flight(flight_id, info) constructor in the Python and Node.js ports. +func NewFlight(flightID string, info []any) *Flight { + return newFlight(flightID, info) +} + +// newFlight builds a flight from one entry of the live feed. +func newFlight(flightID string, info []any) *Flight { + at := func(index int) any { + if index < len(info) { + return info[index] + } + return nil + } + text := func(index int) string { + if value, ok := at(index).(string); ok && value != DefaultText { + return value + } + return "" + } + number := func(index int) *float64 { + if value := at(index); !missing(value) { + return toNumber(value) + } + return nil + } + + flight := &Flight{ + ID: flightID, + ICAO24Bit: text(fieldICAO24Bit), + Heading: number(fieldHeading), + Altitude: number(fieldAltitude), + GroundSpeed: number(fieldGroundSpeed), + Squawk: text(fieldSquawk), + AircraftCode: text(fieldAircraftCode), + Registration: text(fieldRegistration), + OriginAirportIATA: text(fieldOriginIATA), + DestinationAirportIATA: text(fieldDestinationIATA), + Number: text(fieldFlightNumber), + OnGround: number(fieldOnGround), + VerticalSpeed: number(fieldVerticalSpeed), + Callsign: text(fieldCallsign), + AirlineICAO: text(fieldAirlineICAO), + } + + if timestamp := number(fieldTime); timestamp != nil { + seconds := int64(*timestamp) + flight.Time = &seconds + } + if len(flight.Number) >= 2 { + flight.AirlineIATA = flight.Number[:2] + } + flight.setPosition(number(fieldLatitude), number(fieldLongitude)) + + return flight +} + +func (f *Flight) String() string { + return fmt.Sprintf("<(%s) %s - Altitude: %s - Ground Speed: %s - Heading: %s>", + f.AircraftCode, f.Registration, formatOptional(f.Altitude), + formatOptional(f.GroundSpeed), formatOptional(f.Heading)) +} + +// GetAltitude returns the formatted altitude, with its unit. +func (f *Flight) GetAltitude() string { + if f.Altitude == nil { + return DefaultText + } + return formatNumber(*f.Altitude) + " ft" +} + +// GetFlightLevel returns the formatted flight level, with its unit. +func (f *Flight) GetFlightLevel() string { + if f.Altitude == nil { + return DefaultText + } + if *f.Altitude >= 10000 { + return formatNumber(*f.Altitude)[:3] + " FL" + } + return f.GetAltitude() +} + +// GetGroundSpeed returns the formatted ground speed, with its unit. +func (f *Flight) GetGroundSpeed() string { + if f.GroundSpeed == nil { + return DefaultText + } + unit := " kt" + + if *f.GroundSpeed > 1 { + unit = " kts" + } + return formatNumber(*f.GroundSpeed) + unit +} + +// GetHeading returns the formatted heading, with its unit. +func (f *Flight) GetHeading() string { + if f.Heading == nil { + return DefaultText + } + return formatNumber(*f.Heading) + "°" +} + +// GetVerticalSpeed returns the formatted vertical speed, with its unit. +func (f *Flight) GetVerticalSpeed() string { + if f.VerticalSpeed == nil { + return DefaultText + } + return formatNumber(*f.VerticalSpeed) + " fpm" +} + +// detailFieldsByName maps the snake_case names the other ports expose to the +// FlightDetails fields holding them, so a criterion written against Python's +// attributes keeps working here. Built once: the shape never changes. +var detailFieldsByName = sync.OnceValue(func() map[string]int { + byName := make(map[string]int) + structType := reflect.TypeFor[FlightDetails]() + + for index := range structType.NumField() { + name := structType.Field(index).Name + + // Raw has no counterpart in the other ports. + if name == "Raw" { + continue + } + byName[snakeCase(name)] = index + } + return byName +}) + +// snakeCase renders a Go field name the way the other ports spell it, keeping +// acronyms in one piece: DestinationAirportICAO is destination_airport_icao. +func snakeCase(name string) string { + var out strings.Builder + runes := []rune(name) + + for index, char := range runes { + if !unicode.IsUpper(char) { + out.WriteRune(char) + continue + } + + followsLower := index > 0 && !unicode.IsUpper(runes[index-1]) + startsWord := index+1 < len(runes) && !unicode.IsUpper(runes[index+1]) + + if index > 0 && (followsLower || startsWord) { + out.WriteByte('_') + } + out.WriteRune(unicode.ToLower(char)) + } + return out.String() +} + +// fields exposes the flight's values under the names the other ports use, so +// CheckInfo criteria read the same in every language. The detail values join in +// once SetFlightDetails has run, which is when Python's own __dict__ carries +// them. +func (f *Flight) fields() map[string]any { + value := func(number *float64) any { + if number == nil { + return nil + } + return *number + } + timestamp := any(nil) + + if f.Time != nil { + timestamp = float64(*f.Time) + } + + fields := map[string]any{ + "id": f.ID, + "icao_24bit": f.ICAO24Bit, + "latitude": value(f.Latitude), + "longitude": value(f.Longitude), + "heading": value(f.Heading), + "altitude": value(f.Altitude), + "ground_speed": value(f.GroundSpeed), + "squawk": f.Squawk, + "aircraft_code": f.AircraftCode, + "registration": f.Registration, + "time": timestamp, + "origin_airport_iata": f.OriginAirportIATA, + "destination_airport_iata": f.DestinationAirportIATA, + "number": f.Number, + "airline_iata": f.AirlineIATA, + "on_ground": value(f.OnGround), + "vertical_speed": value(f.VerticalSpeed), + "callsign": f.Callsign, + "airline_icao": f.AirlineICAO, + } + + if f.Details != nil { + details := reflect.ValueOf(*f.Details) + + for name, index := range detailFieldsByName() { + fields[name] = detailValue(details.Field(index)) + } + } + return fields +} + +// detailValue unwraps an optional detail so a numeric comparison can read it. +func detailValue(field reflect.Value) any { + if field.Kind() != reflect.Pointer { + return field.Interface() + } + if field.IsNil() { + return nil + } + return field.Elem().Interface() +} + +// CheckInfo checks one or more flight values. A key may carry a "min_" or +// "max_" prefix to compare numerically instead of for equality: +// +// flight.CheckInfo(map[string]any{"min_altitude": 6700, "airline_icao": "THY"}) +// +// Detail names such as "airline_name" work once SetFlightDetails has run. A name +// that exists nowhere is an error, where the Python and Node.js ports ignore it +// and report a match the caller never asked for. +func (f *Flight) CheckInfo(criteria map[string]any) (bool, error) { + fields := f.fields() + + for key, wanted := range criteria { + name, prefix := key, "" + + if strings.HasPrefix(key, "min_") || strings.HasPrefix(key, "max_") { + name, prefix = key[4:], key[:3] + } + + actual, known := fields[name] + + if !known { + return false, fmt.Errorf("%w: unknown flight field %q", ErrFlightRadar, key) + } + + if prefix == "" { + if !equalValues(wanted, actual) { + return false, nil + } + continue + } + + wantedNumber, actualNumber := toNumber(wanted), toNumber(actual) + + if wantedNumber == nil { + return false, fmt.Errorf("%w: %q needs a numeric value, got %v", ErrFlightRadar, key, wanted) + } + if actualNumber == nil { + return false, nil + } + if prefix == "min" && *actualNumber < *wantedNumber { + return false, nil + } + if prefix == "max" && *actualNumber > *wantedNumber { + return false, nil + } + } + return true, nil +} + +// equalValues compares a criterion with a flight value, treating every numeric +// type as one — but only real numbers. Text that merely looks numeric is +// compared as text, so a squawk of "0417" does not match "417", the way it does +// not in the Python and Node.js ports either. +func equalValues(wanted, actual any) bool { + if wanted == nil || actual == nil { + return wanted == nil && actual == nil + } + + wantedNumber, wantedIsNumber := plainNumber(wanted) + actualNumber, actualIsNumber := plainNumber(actual) + + if wantedIsNumber && actualIsNumber { + return wantedNumber == actualNumber + } + + // DeepEqual, not ==: a detail value can be a slice or a map, which == would + // panic on rather than report unequal. + return reflect.DeepEqual(wanted, actual) +} + +// plainNumber reads a numeric value, refusing the numeric-looking string that +// the min_/max_ comparisons do accept. +func plainNumber(value any) (float64, bool) { + if _, isText := value.(string); isText { + return 0, false + } + + number := toNumber(value) + + if number == nil { + return 0, false + } + return *number, true +} + +// SetFlightDetails fills Details in from a [Client.GetFlightDetails] payload. +func (f *Flight) SetFlightDetails(flightDetails map[string]any) { + aircraft := getMap(flightDetails, "aircraft") + airline := getMap(flightDetails, "airline") + airport := getMap(flightDetails, "airport") + + destination := getMap(airport, "destination") + destinationCode := getMap(destination, "code") + destinationInfo := getMap(destination, "info") + destinationPosition := getMap(destination, "position") + destinationCountry := getMap(destinationPosition, "country") + destinationTimezone := getMap(destination, "timezone") + + origin := getMap(airport, "origin") + originCode := getMap(origin, "code") + originInfo := getMap(origin, "info") + originPosition := getMap(origin, "position") + originCountry := getMap(originPosition, "country") + originTimezone := getMap(origin, "timezone") + + history := getMap(flightDetails, "flightHistory") + status := getMap(flightDetails, "status") + + f.Details = &FlightDetails{ + AircraftAge: getString(aircraft, "age"), + AircraftCountryID: getNumber(aircraft, "countryId"), + AircraftHistory: getSlice(history, "aircraft"), + AircraftImages: aircraft["images"], + AircraftModel: getString(getMap(aircraft, "model"), "text"), + + AirlineName: getString(airline, "name"), + AirlineShortName: getString(airline, "short"), + + DestinationAirportAltitude: getNumber(destinationPosition, "altitude"), + DestinationAirportCountryCode: getString(destinationCountry, "code"), + DestinationAirportCountryName: getString(destinationCountry, "name"), + DestinationAirportLatitude: getNumber(destinationPosition, "latitude"), + DestinationAirportLongitude: getNumber(destinationPosition, "longitude"), + DestinationAirportICAO: getString(destinationCode, "icao"), + DestinationAirportBaggage: getString(destinationInfo, "baggage"), + DestinationAirportGate: getString(destinationInfo, "gate"), + DestinationAirportName: getString(destination, "name"), + DestinationAirportTerminal: getString(destinationInfo, "terminal"), + DestinationAirportVisible: getBool(destination, "visible"), + DestinationAirportWebsite: getString(destination, "website"), + DestinationAirportTimezoneAbbr: getString(destinationTimezone, "abbr"), + DestinationAirportTimezoneAbbrName: getString(destinationTimezone, "abbrName"), + DestinationAirportTimezoneName: getString(destinationTimezone, "name"), + DestinationAirportTimezoneOffset: getNumber(destinationTimezone, "offset"), + DestinationAirportTimezoneOffsetHours: getString(destinationTimezone, "offsetHours"), + + OriginAirportAltitude: getNumber(originPosition, "altitude"), + OriginAirportCountryCode: getString(originCountry, "code"), + OriginAirportCountryName: getString(originCountry, "name"), + OriginAirportLatitude: getNumber(originPosition, "latitude"), + OriginAirportLongitude: getNumber(originPosition, "longitude"), + OriginAirportICAO: getString(originCode, "icao"), + OriginAirportBaggage: getString(originInfo, "baggage"), + OriginAirportGate: getString(originInfo, "gate"), + OriginAirportName: getString(origin, "name"), + OriginAirportTerminal: getString(originInfo, "terminal"), + OriginAirportVisible: getBool(origin, "visible"), + OriginAirportWebsite: getString(origin, "website"), + OriginAirportTimezoneAbbr: getString(originTimezone, "abbr"), + OriginAirportTimezoneAbbrName: getString(originTimezone, "abbrName"), + OriginAirportTimezoneName: getString(originTimezone, "name"), + OriginAirportTimezoneOffset: getNumber(originTimezone, "offset"), + OriginAirportTimezoneOffsetHours: getString(originTimezone, "offsetHours"), + + StatusIcon: getString(status, "icon"), + StatusText: getString(status, "text"), + + TimeDetails: getMap(flightDetails, "time"), + Trail: getSlice(flightDetails, "trail"), + + Raw: flightDetails, + } +} diff --git a/go/flightradarapi/flighttrackerconfig.go b/go/flightradarapi/flighttrackerconfig.go new file mode 100644 index 0000000..517ac2d --- /dev/null +++ b/go/flightradarapi/flighttrackerconfig.go @@ -0,0 +1,104 @@ +package flightradarapi + +import ( + "fmt" + "net/url" +) + +// FlightTrackerConfig holds the settings of the Real Time Flight Tracker, used +// by [Client.GetFlights]. Every value is the string FR24 expects in the query. +type FlightTrackerConfig struct { + FAA string `json:"faa"` + Satellite string `json:"satellite"` + MLAT string `json:"mlat"` + FLARM string `json:"flarm"` + ADSB string `json:"adsb"` + GND string `json:"gnd"` + Air string `json:"air"` + Vehicles string `json:"vehicles"` + Estimated string `json:"estimated"` + MaxAge string `json:"maxage"` + Gliders string `json:"gliders"` + Stats string `json:"stats"` + Limit string `json:"limit"` +} + +// NewFlightTrackerConfig returns the config FR24's own web player sends. +func NewFlightTrackerConfig() FlightTrackerConfig { + return FlightTrackerConfig{ + FAA: "1", + Satellite: "1", + MLAT: "1", + FLARM: "1", + ADSB: "1", + GND: "1", + Air: "1", + Vehicles: "1", + Estimated: "1", + MaxAge: "14400", + Gliders: "1", + Stats: "1", + Limit: "5000", + } +} + +// fields maps each FR24 query name to the field holding it, so validation and +// query building never drift apart. +func (c *FlightTrackerConfig) fields() map[string]*string { + return map[string]*string{ + "faa": &c.FAA, + "satellite": &c.Satellite, + "mlat": &c.MLAT, + "flarm": &c.FLARM, + "adsb": &c.ADSB, + "gnd": &c.GND, + "air": &c.Air, + "vehicles": &c.Vehicles, + "estimated": &c.Estimated, + "maxage": &c.MaxAge, + "gliders": &c.Gliders, + "stats": &c.Stats, + "limit": &c.Limit, + } +} + +// Values renders the config as the query FR24's feed expects. +func (c FlightTrackerConfig) Values() url.Values { + values := url.Values{} + for name, field := range c.fields() { + values.Set(name, *field) + } + return values +} + +// update applies name/value pairs, rejecting unknown options and +// non-numeric values the way the feed does. +func (c *FlightTrackerConfig) update(values map[string]string) error { + fields := c.fields() + + for name, value := range values { + field, ok := fields[name] + + if !ok { + return fmt.Errorf("%w: unknown option %q", ErrFlightRadar, name) + } + if !isDecimal(value) { + return fmt.Errorf("%w: value must be a number, got %q for key %q", ErrFlightRadar, value, name) + } + *field = value + } + return nil +} + +// isDecimal reports whether text is a non-empty run of ASCII digits. +func isDecimal(text string) bool { + if text == "" { + return false + } + for _, char := range text { + if char < '0' || char > '9' { + return false + } + } + return true +} diff --git a/go/flightradarapi/parsers.go b/go/flightradarapi/parsers.go new file mode 100644 index 0000000..219471a --- /dev/null +++ b/go/flightradarapi/parsers.go @@ -0,0 +1,360 @@ +package flightradarapi + +import ( + "bytes" + "encoding/json" + "errors" + "math" + "reflect" + "regexp" + "slices" + "strconv" + "strings" + "unicode" + + "golang.org/x/net/html" + "golang.org/x/text/unicode/norm" +) + +// numericPattern is ASCII only: a Unicode-aware \d would match digits such as "٤٣". +var numericPattern = regexp.MustCompile(`^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$`) + +// surroundingSpace is the whitespace trimmed from a numeric field. Pinned to one +// set because Python's str.strip() and JavaScript's trim() disagree on +// U+001C-U+001F and U+FEFF. +const surroundingSpace = " \t\n\r\f\v" + +// Airline is one row of the airlines listing. The JSON tags match the keys the +// Python and Node.js ports use, so NumAircrafts serialises as "n_aircrafts". +type Airline struct { + Name string `json:"Name"` + ICAO string `json:"ICAO"` + IATA string `json:"IATA"` + NumAircrafts *int `json:"n_aircrafts"` +} + +// parseAirlinesHTML parses the airlines listing page into airline records. +func parseAirlinesHTML(page []byte) []Airline { + document, err := html.Parse(bytes.NewReader(page)) + + if err != nil { + log().Warn("parseAirlinesHTML: could not parse the response as HTML") + return []Airline{} + } + + tbody := findElement(document, func(node *html.Node) bool { return node.Data == "tbody" }) + + if tbody == nil { + log().Warn("parseAirlinesHTML: no in response — FR24 page layout may have changed") + return []Airline{} + } + + airlines := []Airline{} + + for _, row := range findElements(tbody, func(node *html.Node) bool { return node.Data == "tr" }) { + cells := findElements(row, func(node *html.Node) bool { return node.Data == "td" }) + notranslate := findElement(row, func(node *html.Node) bool { + return node.Data == "td" && hasClass(node, "notranslate") + }) + + if notranslate == nil { + continue + } + + link := findElement(notranslate, func(node *html.Node) bool { + return node.Data == "a" && strings.HasPrefix(attr(node, "href"), "/data/airlines") + }) + + if link == nil { + continue + } + + name := textContent(link) + + if len(name) < 2 { + continue + } + + airline := Airline{Name: name} + + if len(cells) >= 4 { + codes := textContent(cells[3]) + + switch { + case strings.Contains(codes, " / "): + if parts := strings.Split(codes, " / "); len(parts) == 2 { + airline.IATA = strings.TrimSpace(parts[0]) + airline.ICAO = strings.TrimSpace(parts[1]) + } + case len(codes) == 2: + airline.IATA = codes + case len(codes) == 3: + airline.ICAO = codes + } + } + + if len(cells) >= 5 { + if text := textContent(cells[4]); text != "" { + field := strings.TrimSpace(strings.SplitN(text, " ", 2)[0]) + + if count, err := strconv.Atoi(field); err == nil { + airline.NumAircrafts = &count + } + } + } + airlines = append(airlines, airline) + } + return airlines +} + +// countryToSlug slugifies a country name the way FR24 spells it in its data page +// URLs, so feed rows can be matched against the Country constants. +func countryToSlug(country string) string { + // Diacritics are stripped so a future "Curaçao" still matches "curacao". + decomposed := norm.NFKD.String(country) + var ascii strings.Builder + + for _, char := range decomposed { + if !unicode.Is(unicode.Mn, char) { + ascii.WriteRune(char) + } + } + + var slug strings.Builder + previousHyphen := false + + // Punctuation becomes a hyphen rather than being deleted: FR24's own assets + // are named that way, e.g. flags-small/cote-d-ivoire.svg. + for _, char := range strings.ToLower(ascii.String()) { + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') { + slug.WriteRune(char) + previousHyphen = false + continue + } + if !previousHyphen { + slug.WriteRune('-') + previousHyphen = true + } + } + return strings.Trim(slug.String(), "-") +} + +// toText keeps a text field as a string, or "" when the feed sends anything +// else: get_country_flag(airport.Country) would otherwise slugify a map. +func toText(value any) string { + if text, ok := value.(string); ok { + return text + } + return "" +} + +// toNumber coerces a numeric feed field into a number, or nil when it is +// unusable. An unusable coordinate must not become 0: that would place the +// airport in the Gulf of Guinea instead of marking its position as unknown. +func toNumber(value any) *float64 { + switch typed := value.(type) { + case json.Number: + return parseNumber(string(typed)) + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) { + return nil + } + return &typed + case string: + trimmed := strings.Trim(typed, surroundingSpace) + + if !numericPattern.MatchString(trimmed) { + return nil + } + return parseNumber(trimmed) + default: + return nativeNumber(value) + } +} + +// nativeNumber reads Go's own numeric types, which the JSON decoder never +// produces but a caller building a map by hand does. Booleans stay out: the +// other ports reject them too. +func nativeNumber(value any) *float64 { + reflected := reflect.ValueOf(value) + var number float64 + + switch reflected.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + number = float64(reflected.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + number = float64(reflected.Uint()) + case reflect.Float32: + number = reflected.Float() + default: + return nil + } + + if math.IsNaN(number) || math.IsInf(number, 0) { + return nil + } + return &number +} + +// parseNumber converts numeric text, rejecting anything a float64 cannot hold. +func parseNumber(text string) *float64 { + number, err := strconv.ParseFloat(text, 64) + + if err != nil { + // Underflow keeps its value (Python's float("1e-999") is 0.0); overflow + // has none. + if !errors.Is(err, strconv.ErrRange) || math.IsInf(number, 0) { + return nil + } + } + if math.IsNaN(number) || math.IsInf(number, 0) { + return nil + } + return &number +} + +// parseAirportsJSON parses the airports JSON feed into airports, keeping only +// the wanted countries when any are given. +func parseAirportsJSON(payload []byte, countries []Country) []*Airport { + decoder := json.NewDecoder(bytes.NewReader(bytes.TrimPrefix(payload, []byte("\xef\xbb\xbf")))) + + // Numbers stay as text, so a literal no float64 can hold is rejected + // instead of silently becoming an infinity. + decoder.UseNumber() + + var data map[string]any + + if err := decoder.Decode(&data); err != nil { + log().Warn("parseAirportsJSON: response is not valid JSON — FR24 feed may have changed") + return []*Airport{} + } + + rows, ok := data["rows"].([]any) + + if !ok { + log().Warn(`parseAirportsJSON: no "rows" array in response — FR24 feed may have changed`) + return []*Airport{} + } + + var wanted map[string]bool + + if countries != nil { + wanted = make(map[string]bool, len(countries)) + + for _, country := range countries { + wanted[countryToSlug(string(country))] = true + } + } + + matched := make(map[string]bool, len(wanted)) + var unpositioned []string + airports := []*Airport{} + + for _, entry := range rows { + row, ok := entry.(map[string]any) + + if !ok { + continue + } + + // Slugified only when filtering: this loop runs over every airport in + // the feed. + if wanted != nil { + slug := countryToSlug(toText(row["country"])) + + if !wanted[slug] { + continue + } + matched[slug] = true + } + + latitude := toNumber(row["lat"]) + longitude := toNumber(row["lon"]) + + // One bad coordinate drops both: half a position reads as located. + if latitude == nil || longitude == nil { + latitude, longitude = nil, nil + unpositioned = append(unpositioned, toText(row["name"])) + } + + airports = append(airports, newAirportFromBasicInfo(basicAirportInfo{ + Name: toText(row["name"]), + ICAO: toText(row["icao"]), + IATA: toText(row["iata"]), + Latitude: latitude, + Longitude: longitude, + Altitude: toNumber(row["alt"]), + Country: toText(row["country"]), + })) + } + + // One line, not one per row: the feed carries every airport. + if len(unpositioned) > 0 { + log().Warn("parseAirportsJSON: airports with unusable coordinates carry no position", + "count", len(unpositioned), "examples", unpositioned[:min(3, len(unpositioned))]) + } + + if wanted != nil { + var missing []string + + for slug := range wanted { + if !matched[slug] { + missing = append(missing, slug) + } + } + if len(missing) > 0 { + slices.Sort(missing) + log().Warn("parseAirportsJSON: no airports found for some countries — check the Country constants", + "countries", strings.Join(missing, ", ")) + } + } + return airports +} + +// --- HTML helpers --- + +func findElement(root *html.Node, match func(*html.Node) bool) *html.Node { + for node := range root.Descendants() { + if node.Type == html.ElementNode && match(node) { + return node + } + } + return nil +} + +func findElements(root *html.Node, match func(*html.Node) bool) []*html.Node { + var found []*html.Node + + for node := range root.Descendants() { + if node.Type == html.ElementNode && match(node) { + found = append(found, node) + } + } + return found +} + +func attr(node *html.Node, name string) string { + for _, attribute := range node.Attr { + if attribute.Key == name { + return attribute.Val + } + } + return "" +} + +func hasClass(node *html.Node, name string) bool { + return slices.Contains(strings.Fields(attr(node, "class")), name) +} + +// textContent concatenates the node's text, trimming each piece, as +// BeautifulSoup's get_text(strip=True) does. +func textContent(root *html.Node) string { + var text strings.Builder + + for node := range root.Descendants() { + if node.Type == html.TextNode { + text.WriteString(strings.TrimSpace(node.Data)) + } + } + return text.String() +} diff --git a/go/flightradarapi/parsers_test.go b/go/flightradarapi/parsers_test.go new file mode 100644 index 0000000..14c8bcd --- /dev/null +++ b/go/flightradarapi/parsers_test.go @@ -0,0 +1,450 @@ +package flightradarapi + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "testing" +) + +// Offline parser tests against the bundled fixtures, mirroring +// python/tests/test_parsers_offline.py and nodejs/tests/testParsersOffline.js. +// When FR24 changes a page or feed, update the fixtures: the assertions here +// guard the parser's invariants, not byte-for-byte equality with production. + +func loadFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) + + if err != nil { + t.Fatalf("could not read fixture %s: %v", name, err) + } + return data +} + +func airlinesByName(t *testing.T) map[string]Airline { + t.Helper() + byName := map[string]Airline{} + + for _, airline := range parseAirlinesHTML(loadFixture(t, "airlines.html")) { + byName[airline.Name] = airline + } + return byName +} + +func airportsByIATA(t *testing.T, airports []*Airport) map[string]*Airport { + t.Helper() + byIATA := map[string]*Airport{} + + for _, airport := range airports { + byIATA[airport.IATA] = airport + } + return byIATA +} + +// airportsFromRow parses a one-row feed built from a raw JSON literal, the way +// both other ports do it. +func airportsFromRow(t *testing.T, row string) []*Airport { + t.Helper() + airports := parseAirportsJSON([]byte(`{"rows":[`+row+`]}`), nil) + + if len(airports) != 1 { + t.Fatalf("got %d airports for row %s, want 1", len(airports), row) + } + return airports +} + +// --- parseAirlinesHTML --- + +func TestParseAirlinesHTMLExtractsKnownRows(t *testing.T) { + byName := airlinesByName(t) + + for _, name := range []string{"LATAM Airlines", "Gol", "Delta Air Lines"} { + if _, ok := byName[name]; !ok { + t.Errorf("missing airline %q", name) + } + } +} + +func TestParseAirlinesHTMLSplitsIATAAndICAO(t *testing.T) { + byName := airlinesByName(t) + + for _, expected := range []struct{ name, iata, icao string }{ + {"LATAM Airlines", "LA", "LAN"}, + {"Gol", "G3", "GLO"}, + } { + airline := byName[expected.name] + + if airline.IATA != expected.iata || airline.ICAO != expected.icao { + t.Errorf("%s: got IATA %q / ICAO %q, want %q / %q", + expected.name, airline.IATA, airline.ICAO, expected.iata, expected.icao) + } + } +} + +func TestParseAirlinesHTMLHandlesIATAOrICAOOnly(t *testing.T) { + byName := airlinesByName(t) + + if byName["Sky2"].IATA != "SK" || byName["Sky2"].ICAO != "" { + t.Errorf("Sky2: got IATA %q / ICAO %q", byName["Sky2"].IATA, byName["Sky2"].ICAO) + } + if byName["SkyTeam"].ICAO != "SKT" || byName["SkyTeam"].IATA != "" { + t.Errorf("SkyTeam: got IATA %q / ICAO %q", byName["SkyTeam"].IATA, byName["SkyTeam"].ICAO) + } +} + +func TestParseAirlinesHTMLParsesAircraftCount(t *testing.T) { + byName := airlinesByName(t) + + for _, expected := range []struct { + name string + count int + }{{"LATAM Airlines", 340}, {"Gol", 140}} { + count := byName[expected.name].NumAircrafts + + if count == nil || *count != expected.count { + t.Errorf("%s: got %v aircraft, want %d", expected.name, count, expected.count) + } + } +} + +func TestParseAirlinesHTMLSkipsInvalidRows(t *testing.T) { + airlines := parseAirlinesHTML(loadFixture(t, "airlines.html")) + + // 5 valid rows; 2 invalid (no notranslate, wrong href) must be skipped. + if len(airlines) != 5 { + t.Errorf("got %d airlines, want 5", len(airlines)) + } +} + +func TestParseAirlinesHTMLEmptyInputReturnsEmptyList(t *testing.T) { + for _, page := range []string{"", "

no tbody here

"} { + if airlines := parseAirlinesHTML([]byte(page)); len(airlines) != 0 { + t.Errorf("got %d airlines for %q, want 0", len(airlines), page) + } + } +} + +// --- parseAirportsJSON --- + +func TestParseAirportsJSONExtractsBasicFields(t *testing.T) { + byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil)) + + for _, code := range []string{"GRU", "GIG"} { + if _, ok := byIATA[code]; !ok { + t.Fatalf("missing airport %q", code) + } + } + + gru := byIATA["GRU"] + + if gru.ICAO != "SBGR" || gru.Country != "Brazil" { + t.Errorf("GRU: got ICAO %q country %q", gru.ICAO, gru.Country) + } + if gru.Latitude == nil || math.Abs(*gru.Latitude-(-23.429991)) > 1e-6 { + t.Errorf("GRU latitude: got %v", gru.Latitude) + } + if gru.Longitude == nil || math.Abs(*gru.Longitude-(-46.4674)) > 1e-6 { + t.Errorf("GRU longitude: got %v", gru.Longitude) + } + if gru.Altitude == nil || *gru.Altitude != 2436 { + t.Errorf("GRU altitude: got %v, want 2436", gru.Altitude) + } +} + +func TestParseAirportsJSONFormatsAltitudeWithoutDecimals(t *testing.T) { + // Both other ports report 2436, never 2436.0. + byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil)) + + if got := formatNumber(*byIATA["GRU"].Altitude); got != "2436" { + t.Errorf("got altitude %q, want \"2436\"", got) + } +} + +func TestParseAirportsJSONKeepsEveryCountryWithoutFilter(t *testing.T) { + countries := map[string]bool{} + + for _, airport := range parseAirportsJSON(loadFixture(t, "airports.json"), nil) { + countries[airport.Country] = true + } + + for _, country := range []string{"Brazil", "United States", "Spain"} { + if !countries[country] { + t.Errorf("missing country %q", country) + } + } +} + +func TestParseAirportsJSONFiltersByCountrySlug(t *testing.T) { + airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{"united-states"}) + + if len(airports) == 0 { + t.Fatal("no airports returned") + } + for _, airport := range airports { + if airport.Country != "United States" { + t.Errorf("got country %q, want United States", airport.Country) + } + } +} + +func TestParseAirportsJSONAcceptsSeveralCountries(t *testing.T) { + airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{"brazil", "spain"}) + countries := map[string]bool{} + + for _, airport := range airports { + countries[airport.Country] = true + } + if len(countries) != 2 || !countries["Brazil"] || !countries["Spain"] { + t.Errorf("got countries %v, want Brazil and Spain", countries) + } +} + +func TestParseAirportsJSONInvalidCoordinatesBecomeNil(t *testing.T) { + // Regression: invalid coords used to be coerced to (0, 0), placing the + // airport in the Gulf of Guinea. + byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil)) + bad := byIATA["BAD"] + + if bad == nil { + t.Fatal("missing airport BAD") + } + if bad.Latitude != nil || bad.Longitude != nil { + t.Errorf("got position (%v, %v), want no position", bad.Latitude, bad.Longitude) + } +} + +func TestParseAirportsJSONRejectsNumericLookingJunk(t *testing.T) { + payload, err := json.Marshal(map[string]any{"rows": []any{map[string]any{ + "name": "Junk Airport", "iata": "JNK", "icao": "JJNK", + "lat": "43.30 N", "lon": -8.37725, "country": "Spain", "alt": "-1", + }}}) + + if err != nil { + t.Fatal(err) + } + + airport := parseAirportsJSON(payload, nil)[0] + + // One bad coordinate drops both; altitude is independent and survives. + if airport.Latitude != nil || airport.Longitude != nil { + t.Errorf("got position (%v, %v), want no position", airport.Latitude, airport.Longitude) + } + if airport.Altitude == nil || *airport.Altitude != -1 { + t.Errorf("got altitude %v, want -1", airport.Altitude) + } +} + +func TestParseAirportsJSONAgreesWithOtherPortsOnUnicode(t *testing.T) { + cases := []struct { + name string + value string + expected *float64 + }{ + // Arabic-Indic digits, which a Unicode-aware \d matched before. + {"arabic-indic digits", "٤٣", nil}, + // U+FEFF, which JavaScript's trim() drops and Python's strip() keeps. + {"leading byte-order mark", "\ufeff43", nil}, + // U+001C, which Python's strip() drops and JavaScript's trim() keeps. + {"leading file separator", "\u001c43", nil}, + {"plain spaces are trimmed", " 43 ", float64Ptr(43)}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + row, err := json.Marshal(map[string]any{ + "name": "X", "iata": "XXX", "icao": "XXXX", "country": "Spain", + "lat": testCase.value, "lon": testCase.value, "alt": testCase.value, + }) + + if err != nil { + t.Fatal(err) + } + + airport := airportsFromRow(t, string(row))[0] + + if !equalOptional(airport.Altitude, testCase.expected) { + t.Errorf("got altitude %v, want %v", airport.Altitude, testCase.expected) + } + }) + } +} + +func TestParseAirportsJSONNumericCoercion(t *testing.T) { + // Keep in step with the same list in the Python and Node.js suites. + cases := []struct { + name string + literal string + expected *float64 + }{ + {"whitespace", `" "`, nil}, + {"an array holding a number", `[43]`, nil}, + {"a coordinate with a hemisphere suffix", `"43.30 N"`, nil}, + {"an exponent overflowing a double", `"1e999"`, nil}, + {"400 plain digits", `"` + repeat("1", 400) + `"`, nil}, + {"a whole number as a string", `"2436"`, float64Ptr(2436)}, + {"a negative decimal", `-23.4`, float64Ptr(-23.4)}, + {"a genuine zero", `0`, float64Ptr(0)}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + row := fmt.Sprintf( + `{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain","lat":%s,"lon":%s,"alt":%s}`, + testCase.literal, testCase.literal, testCase.literal) + airport := airportsFromRow(t, row)[0] + + for label, got := range map[string]*float64{ + "latitude": airport.Latitude, + "longitude": airport.Longitude, + "altitude": airport.Altitude, + } { + if !equalOptional(got, testCase.expected) { + t.Errorf("%s: got %v, want %v", label, got, testCase.expected) + } + } + }) + } +} + +func TestParseAirportsJSONSurvivesRawNumbersNoDoubleCanHold(t *testing.T) { + for _, literal := range []string{repeat("1", 400), "9007199254740993", "-" + repeat("1", 400)} { + row := fmt.Sprintf( + `{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain","lat":%s,"lon":1,"alt":2}`, literal) + latitude := airportsFromRow(t, row)[0].Latitude + + if latitude != nil && (math.IsInf(*latitude, 0) || math.IsNaN(*latitude)) { + t.Errorf("%s: got %v, want nil or a finite number", literal, *latitude) + } + } +} + +func TestParseAirportsJSONKeepsTextFieldsAsStrings(t *testing.T) { + // Anything else breaks GetCountryFlag(airport.Country). + for _, literal := range []string{"null", "0", "false", "true", "[]", "{}", "123"} { + row := fmt.Sprintf( + `{"name":%s,"iata":%s,"icao":%s,"country":%s,"lat":1,"lon":2,"alt":3}`, + literal, literal, literal, literal) + airport := airportsFromRow(t, row)[0] + + if airport.Name != "" || airport.IATA != "" || airport.ICAO != "" || airport.Country != "" { + t.Errorf("%s: got %q/%q/%q/%q, want empty strings", + literal, airport.Name, airport.IATA, airport.ICAO, airport.Country) + } + } +} + +func TestParseAirportsJSONCountrySpellingAndSlugRoundTrip(t *testing.T) { + byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil)) + ann := byIATA["VBA"] + + if ann == nil { + t.Fatal("missing airport VBA") + } + if ann.Country != "Myanmar (Burma)" { + t.Errorf("got country %q", ann.Country) + } + if slug := countryToSlug(ann.Country); slug != "myanmar-burma" { + t.Errorf("got slug %q, want myanmar-burma", slug) + } + if ann.Altitude == nil || *ann.Altitude != 43 { + t.Errorf("got altitude %v, want 43", ann.Altitude) + } +} + +func TestParseAirportsJSONFilterAcceptsEitherCountrySpelling(t *testing.T) { + for _, spelling := range []Country{"myanmar-burma", "Myanmar (Burma)", CountryMyanmarBurma} { + airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{spelling}) + + if len(airports) != 1 { + t.Errorf("%q: got %d airports, want 1", spelling, len(airports)) + } + } +} + +func TestParseAirportsJSONSkipsRowsThatAreNotObjects(t *testing.T) { + payload := []byte(`{"rows":[[1,2,3],"x",7,null,` + + `{"name":"Real","iata":"RRR","icao":"RRRR","country":"Spain","lat":1,"lon":2,"alt":3}]}`) + airports := parseAirportsJSON(payload, nil) + + if len(airports) != 1 || airports[0].IATA != "RRR" { + t.Errorf("got %d airports, want only RRR", len(airports)) + } +} + +func TestParseAirportsJSONUnknownCountryReturnsEmptyList(t *testing.T) { + if airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{"atlantis"}); len(airports) != 0 { + t.Errorf("got %d airports, want 0", len(airports)) + } +} + +func TestParseAirportsJSONInvalidPayloadReturnsEmptyList(t *testing.T) { + for _, payload := range []string{"", "{}", "not json"} { + if airports := parseAirportsJSON([]byte(payload), nil); len(airports) != 0 { + t.Errorf("%q: got %d airports, want 0", payload, len(airports)) + } + } +} + +// --- countryToSlug --- + +func TestCountryToSlugMatchesCountryConstants(t *testing.T) { + cases := map[string]string{ + "United States": "united-states", + "Democratic Republic Of The Congo": "democratic-republic-of-the-congo", + "Curacao": "curacao", + "Curaçao": "curacao", + "": "", + } + + for country, expected := range cases { + if slug := countryToSlug(country); slug != expected { + t.Errorf("%q: got %q, want %q", country, slug, expected) + } + } +} + +func TestCountryToSlugStripsParenthesesForFlagURLs(t *testing.T) { + // These 404'd while the slug was a plain space-to-hyphen replacement. + cases := map[string]string{ + "Myanmar (Burma)": "myanmar-burma", + "Cocos (Keeling) Islands": "cocos-keeling-islands", + "Falkland Islands (Malvinas)": "falkland-islands-malvinas", + "Timor-Leste (East Timor)": "timor-leste-east-timor", + } + + for country, expected := range cases { + if slug := countryToSlug(country); slug != expected { + t.Errorf("%q: got %q, want %q", country, slug, expected) + } + } +} + +func TestCountryConstantsRoundTripThroughTheSlugifier(t *testing.T) { + for _, country := range []Country{CountryBrazil, CountryMyanmarBurma, CountryUnitedStates} { + if slug := countryToSlug(string(country)); slug != string(country) { + t.Errorf("%q: got %q", country, slug) + } + } +} + +func equalOptional(got, want *float64) bool { + if got == nil || want == nil { + return got == nil && want == nil + } + return *got == *want +} + +// float64Ptr is the shorthand the table-driven tests need for optional numbers. +func float64Ptr(value float64) *float64 { return &value } + +func repeat(text string, times int) string { + result := make([]byte, 0, len(text)*times) + + for range times { + result = append(result, text...) + } + return string(result) +} diff --git a/go/flightradarapi/ports_test.go b/go/flightradarapi/ports_test.go new file mode 100644 index 0000000..cc2c9f9 --- /dev/null +++ b/go/flightradarapi/ports_test.go @@ -0,0 +1,295 @@ +package flightradarapi + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" +) + +// countries.go and zones.go are generated from the Python port, which is the +// source of truth for both. These tests turn "keep the ports in sync" from a +// promise in CONTRIBUTING.md into a failing build: when the Python data moves +// ahead, they say exactly which entries drifted. + +// pythonSource reads a file of the Python port, skipping the test when this +// module was vendored without the rest of the repository. +func pythonSource(t *testing.T, name string) string { + t.Helper() + path := filepath.Join("..", "..", "python", "FlightRadarAPI", name) + source, err := os.ReadFile(path) + + if errors.Is(err, fs.ErrNotExist) { + t.Skipf("%s is not present: nothing to compare against", path) + } + if err != nil { + t.Fatalf("could not read %s: %v", path, err) + } + return string(source) +} + +var pythonEnumMember = regexp.MustCompile(`(?m)^ [A-Z_]+ = "([a-z0-9-]+)"$`) + +func TestCountryConstantsMatchThePythonEnum(t *testing.T) { + source := pythonSource(t, "core.py") + var wanted []Country + + for _, match := range pythonEnumMember.FindAllStringSubmatch(source, -1) { + wanted = append(wanted, Country(match[1])) + } + + if len(wanted) == 0 { + t.Fatal("no country members found in core.py — has the enum moved?") + } + countries := AllCountries() + + if reflect.DeepEqual(wanted, countries) { + return + } + + inGo := make(map[Country]bool, len(countries)) + + for _, country := range countries { + inGo[country] = true + } + + inPython := make(map[Country]bool, len(wanted)) + + for _, country := range wanted { + inPython[country] = true + + if !inGo[country] { + t.Errorf("missing from countries.go: %q — regenerate it from core.py", country) + } + } + + for _, country := range countries { + if !inPython[country] { + t.Errorf("not in the Python enum: %q — regenerate countries.go from core.py", country) + } + } + + // Same members, different order: the constants are still usable, but the + // generated file no longer reflects its source. + if len(wanted) == len(countries) && !t.Failed() { + t.Error("countries.go lists the same countries in a different order than core.py") + } +} + +func TestStaticZonesMatchThePythonSource(t *testing.T) { + source := pythonSource(t, "zones.py") + _, literal, found := strings.Cut(source, "static_zones = ") + + if !found { + t.Fatal(`no "static_zones" assignment in zones.py — has the source moved?`) + } + + // The Python literal is valid JSON, and Zone carries the feed's own field + // names, so it decodes straight into the type under test. + var wanted map[string]Zone + + if err := json.Unmarshal([]byte(strings.TrimSpace(literal)), &wanted); err != nil { + t.Fatalf("could not read the zones of the Python port: %v", err) + } + if len(wanted) == 0 { + t.Fatal("no zones found in zones.py") + } + + for name, zone := range wanted { + got, ok := staticZones[name] + + if !ok { + t.Errorf("missing from zones.go: %q — regenerate it from zones.py", name) + continue + } + compareZone(t, name, got, zone) + } + + for name := range staticZones { + if _, ok := wanted[name]; !ok { + t.Errorf("not in the Python source: %q — regenerate zones.go from zones.py", name) + } + } +} + +// compareZone reports the differing field rather than dumping both trees, which +// for a zone with subzones runs to several screens. +func compareZone(t *testing.T, path string, got, want Zone) { + t.Helper() + + for field, pair := range map[string][2]float64{ + "tl_y": {got.TLY, want.TLY}, + "tl_x": {got.TLX, want.TLX}, + "br_y": {got.BRY, want.BRY}, + "br_x": {got.BRX, want.BRX}, + } { + if pair[0] != pair[1] { + t.Errorf("zone %s.%s: got %v, want %v from zones.py", path, field, pair[0], pair[1]) + } + } + + for name, subzone := range want.Subzones { + sub, ok := got.Subzones[name] + + if !ok { + t.Errorf("subzone %s.%s is missing from zones.go", path, name) + continue + } + compareZone(t, path+"."+name, sub, subzone) + } + + for name := range got.Subzones { + if _, ok := want.Subzones[name]; !ok { + t.Errorf("subzone %s.%s is not in zones.py", path, name) + } + } +} + +func TestFlightTrackerConfigMatchesThePythonDataclass(t *testing.T) { + source := pythonSource(t, "flight_tracker_config.py") + fields := regexp.MustCompile(`(?m)^ ([a-z]+): str = "([0-9]+)"$`).FindAllStringSubmatch(source, -1) + + if len(fields) == 0 { + t.Fatal("no fields found in flight_tracker_config.py — has the dataclass moved?") + } + + values := NewFlightTrackerConfig().Values() + + for _, field := range fields { + name, wanted := field[1], field[2] + + if got := values.Get(name); got != wanted { + t.Errorf("option %q: got %q, want %q from the Python dataclass", name, got, wanted) + } + } + if len(values) != len(fields) { + t.Errorf("got %d options, want the %d of the Python dataclass", len(values), len(fields)) + } +} + +func TestFlightFieldsMatchThePythonAttributes(t *testing.T) { + // CheckInfo criteria are written against these names, so a Python filter + // ported over must keep working. + source := pythonSource(t, filepath.Join("entities", "flight.py")) + assigned := regexp.MustCompile(`(?m)^\s+self\.([a-z_0-9]+) =`).FindAllStringSubmatch(source, -1) + + if len(assigned) == 0 { + t.Fatal("no attributes found in flight.py — has the class moved?") + } + + flight := newFlight("x", feedRow()) + flight.SetFlightDetails(map[string]any{}) + fields := flight.fields() + + for _, match := range assigned { + if _, ok := fields[match[1]]; !ok { + t.Errorf("CheckInfo cannot reach %q, which the Python port exposes", match[1]) + } + } + + // Set by Entity._set_position in Python, so the regex above cannot see them. + for _, name := range []string{"latitude", "longitude"} { + if _, ok := fields[name]; !ok { + t.Errorf("CheckInfo cannot reach %q", name) + } + } +} + +// pascalCase renders a Python method name the way this port spells it: +// get_bounds_by_point becomes GetBoundsByPoint. +func pascalCase(name string) string { + var out strings.Builder + + for _, part := range strings.Split(name, "_") { + if part == "" { + continue + } + out.WriteString(strings.ToUpper(part[:1]) + part[1:]) + } + return out.String() +} + +var pythonMethod = regexp.MustCompile(`(?m)^ def ([a-z_][a-z_0-9]*)\(`) + +// publicMethods lists the methods a Python class exposes, skipping the private +// and dunder ones. +func publicMethods(t *testing.T, source string) []string { + t.Helper() + var names []string + + for _, match := range pythonMethod.FindAllStringSubmatch(source, -1) { + if !strings.HasPrefix(match[1], "_") { + names = append(names, match[1]) + } + } + if len(names) == 0 { + t.Fatal("no public methods found — has the Python source moved?") + } + return names +} + +func TestClientMethodsMatchThePythonAPI(t *testing.T) { + client := reflect.TypeFor[*Client]() + + for _, name := range publicMethods(t, pythonSource(t, "api.py")) { + if _, ok := client.MethodByName(pascalCase(name)); !ok { + t.Errorf("FlightRadar24API.%s has no Client.%s in this port", name, pascalCase(name)) + } + } +} + +func TestEntityMethodsMatchThePythonAPI(t *testing.T) { + cases := []struct { + file string + goType reflect.Type + }{ + {"entity.py", reflect.TypeFor[*Flight]()}, // inherited by both entities + {"airport.py", reflect.TypeFor[*Airport]()}, + {"flight.py", reflect.TypeFor[*Flight]()}, + } + + // Python exposes these as classmethods, which this port spells as package + // functions. Named here so a new one upstream fails the test. + constructors := map[string]any{ + "from_basic_info": NewAirportFromBasicInfo, + "from_info": NewAirportFromInfo, + "from_details": NewAirportFromDetails, + } + + for _, testCase := range cases { + source := pythonSource(t, filepath.Join("entities", testCase.file)) + + for _, name := range publicMethods(t, source) { + if constructor, isFactory := constructors[name]; isFactory { + if constructor == nil { + t.Errorf("%s.%s has no constructor in this port", testCase.file, name) + } + continue + } + if _, ok := testCase.goType.MethodByName(pascalCase(name)); !ok { + t.Errorf("%s.%s has no %s.%s in this port", + testCase.file, name, testCase.goType, pascalCase(name)) + } + } + } +} + +func TestAllCountriesHandsBackACopy(t *testing.T) { + countries := AllCountries() + + if len(countries) == 0 { + t.Fatal("no countries returned") + } + + first := countries[0] + countries[0] = "mutated" + + if AllCountries()[0] != first { + t.Error("mutating the result must not touch the package's own data") + } +} diff --git a/go/flightradarapi/request.go b/go/flightradarapi/request.go new file mode 100644 index 0000000..ae74ef8 --- /dev/null +++ b/go/flightradarapi/request.go @@ -0,0 +1,728 @@ +package flightradarapi + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "compress/zlib" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math" + "math/rand" + "net/http" + "net/url" + "strings" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/andybalholm/brotli" +) + +// MaxResponseBytes is the default budget for a response body, before and after +// decompression. A compressed body is trusted only as far as its expanded size: +// brotli reaches ratios high enough to exhaust memory from a few kilobytes on +// the wire. +const MaxResponseBytes = 64 * 1024 * 1024 + +// DefaultTimeout is the per-request timeout when none is given. +const DefaultTimeout = 30 * time.Second + +var gzipMagic = []byte{0x1f, 0x8b} + +// decoders maps a Content-Encoding token to its decompressor. Owning the +// decoding (the transport is told not to) is what makes the budget enforceable. +var decoders = map[string]func(data []byte, limit int) ([]byte, error){ + "gzip": decompressGzip, + "deflate": decompressDeflate, + "br": decompressBrotli, +} + +// supportedEncodings is advertised on every request. Derived from the decoder +// table, so advertising an encoding with no decoder is not expressible. +const supportedEncodings = "gzip, deflate, br" + +var logger atomic.Pointer[slog.Logger] + +// SetLogger routes this package's warnings to l, process-wide; nil restores the +// default. Without it, slog.Default() is used, so an application that configures +// slog receives them without calling this at all. +// +// Package-scoped rather than per-client on purpose: every message here reports +// that FR24 changed a payload's shape, which is true for the whole process. The +// Python and Node.js ports use a module logger for the same reason. +func SetLogger(l *slog.Logger) { logger.Store(l) } + +func log() *slog.Logger { + if l := logger.Load(); l != nil { + return l + } + return slog.Default() +} + +// limitError reports a body that grew past its budget. +func limitError(format string, args ...any) error { + return &DecompressionLimitError{Message: fmt.Sprintf(format, args...)} +} + +// readBounded reads everything from reader, refusing a stream past limit. +func readBounded(reader io.Reader, limit int, what string) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(reader, int64(limit)+1)) + + if err != nil { + return nil, err + } + if len(body) > limit { + return nil, limitError("%s expands past the %d byte decompression limit", what, limit) + } + return body, nil +} + +// decompressGzip inflates gzip bytes, across members and tolerating trailing +// padding, the way libcurl does. +func decompressGzip(data []byte, limit int) ([]byte, error) { + source := bytes.NewReader(data) + reader, err := gzip.NewReader(source) + + if err != nil { + return nil, err + } + defer reader.Close() + + var decoded bytes.Buffer + + for { + reader.Multistream(false) + + // A short read must raise rather than return what arrived: the caller + // cannot tell truncated JSON from a malformed feed. + member, err := readBounded(reader, limit-decoded.Len(), "gzip body") + + if err != nil { + // Reported against the budget, not against what was left of it: + // the members already decoded are not the caller's business. + if errors.Is(err, ErrDecompressionLimit) { + return nil, limitError("gzip body expands past the %d byte decompression limit", limit) + } + return nil, err + } + decoded.Write(member) + + // Another member only when the tail looks like one; trailing padding + // is not an error. + tail := data[len(data)-source.Len():] + + if !bytes.HasPrefix(tail, gzipMagic) { + return decoded.Bytes(), nil + } + if err := reader.Reset(source); err != nil { + return nil, err + } + } +} + +// decompressDeflate inflates a deflate body in either shape it arrives in: RFC +// 9110 says zlib-wrapped, plenty of servers send raw. +func decompressDeflate(data []byte, limit int) ([]byte, error) { + source := bytes.NewReader(data) + zlibReader, err := zlib.NewReader(source) + + if err == nil { + defer zlibReader.Close() + decoded, err := readBounded(zlibReader, limit, "deflate body") + + if err == nil { + return decoded, nil + } + if errors.Is(err, ErrDecompressionLimit) { + return nil, err + } + } + + source.Reset(data) + rawReader := flate.NewReader(source) + defer rawReader.Close() + + decoded, err := readBounded(rawReader, limit, "deflate body") + + if err != nil { + return nil, err + } + + // A body that is not deflate at all can still inflate to plausible bytes, + // so anything left over means this was never a deflate stream. The zlib + // path above tolerates the same trailing bytes, because its checksum + // already vouched for the body. + if source.Len() > 0 { + return nil, errors.New("body is not a complete deflate stream") + } + return decoded, nil +} + +// decompressBrotli decompresses brotli bytes, refusing a body past limit. +func decompressBrotli(data []byte, limit int) ([]byte, error) { + return readBounded(brotli.NewReader(bytes.NewReader(data)), limit, "brotli body") +} + +// RetryPolicy retries transient failures: a Cloudflare block, a timeout, or a +// network error. The zero value retries nothing. +type RetryPolicy struct { + // MaxAttempts is the total number of attempts, including the first. + MaxAttempts int + // BaseDelay is the first backoff sleep. + BaseDelay time.Duration + // MaxDelay caps the exponential backoff. Zero means uncapped. + MaxDelay time.Duration + // Jitter is the random span added to each sleep. + Jitter time.Duration +} + +// NewRetryPolicy returns a policy with the usual exponential backoff: +// 1s base, 30s cap, 500ms jitter. +func NewRetryPolicy(maxAttempts int) (*RetryPolicy, error) { + policy := &RetryPolicy{ + MaxAttempts: maxAttempts, + BaseDelay: time.Second, + MaxDelay: 30 * time.Second, + Jitter: 500 * time.Millisecond, + } + if err := policy.validate(); err != nil { + return nil, err + } + return policy, nil +} + +func (p *RetryPolicy) validate() error { + if p.MaxAttempts < 1 { + return fmt.Errorf("%w: MaxAttempts must be >= 1", ErrFlightRadar) + } + if p.BaseDelay < 0 || p.MaxDelay < 0 || p.Jitter < 0 { + return fmt.Errorf("%w: BaseDelay, MaxDelay and Jitter must all be >= 0", ErrFlightRadar) + } + return nil +} + +// SleepFor returns the backoff before the attempt after the given 0-based one. +// +// Every field is public, so this must hold for values [New] never saw: a zero +// MaxDelay caps nothing rather than capping everything at zero, and a negative +// Jitter adds nothing rather than panicking. +func (p *RetryPolicy) SleepFor(attemptIndex int) time.Duration { + delay := float64(p.BaseDelay) * math.Pow(2, float64(attemptIndex)) + + if capped := float64(p.MaxDelay); capped > 0 && delay > capped { + delay = capped + } + + jitter := time.Duration(0) + + if p.Jitter > 0 { + jitter = time.Duration(rand.Int63n(int64(p.Jitter) + 1)) + } + return time.Duration(delay) + jitter +} + +// isTransient reports whether a failure is worth retrying. +func isTransient(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, ErrDecompressionLimit) { + return false + } + if errors.Is(err, ErrCloudflare) || errors.Is(err, context.DeadlineExceeded) { + return true + } + + var urlErr *url.Error + return errors.As(err, &urlErr) +} + +// runWithRetry executes fn, retrying transient failures per the policy. +func runWithRetry[T any](ctx context.Context, policy *RetryPolicy, fn func() (T, error)) (T, error) { + if policy == nil || policy.MaxAttempts <= 1 { + return fn() + } + + var zero T + var lastErr error + + for attempt := range policy.MaxAttempts { + result, err := fn() + + if err == nil { + return result, nil + } + if !isTransient(err) { + return zero, err + } + lastErr = err + + if attempt < policy.MaxAttempts-1 { + select { + case <-ctx.Done(): + return zero, ctx.Err() + case <-time.After(policy.SleepFor(attempt)): + } + } + } + return zero, lastErr +} + +// TLSProfile approximates a browser's TLS handshake. Go fixes its own cipher +// suite ordering, so this narrows the offered set and curve order rather than +// reproducing a JA3 exactly; see [Options.HTTPClient] for full impersonation. +type TLSProfile struct { + CipherSuites []uint16 + CurvePreferences []tls.CurveID + MinVersion uint16 + MaxVersion uint16 +} + +// Chrome136Profile is the TLS profile used by default. +func Chrome136Profile() TLSProfile { + return TLSProfile{ + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_RSA_WITH_AES_256_CBC_SHA, + }, + CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384, tls.CurveP521}, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, + } +} + +// jarKey carries a request's cookie jar to the redirect handler. +type jarKey struct{} + +// maxRedirects is the limit net/http applies by default, kept when this package +// takes the redirect handler over. +const maxRedirects = 10 + +// bankRedirectCookies keeps the jar in step across redirect hops. net/http +// follows them internally, so without this the hop that hands out a cookie is +// never seen, and a cross-host hop travels with no Cookie header at all. +func bankRedirectCookies(request *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("%w: stopped after %d redirects", ErrFlightRadar, maxRedirects) + } + + jar, _ := request.Context().Value(jarKey{}).(*cookieJar) + + if jar == nil { + return nil + } + + // Credited to the host that answered, not to the one being redirected to. + if response := request.Response; response != nil && len(via) > 0 { + jar.store(via[len(via)-1].URL, response.Header.Values("Set-Cookie")) + } + + if header := jar.header(request.URL); header != "" { + request.Header.Set("cookie", header) + } else { + request.Header.Del("cookie") + } + return nil +} + +// newHTTPClient builds a client that impersonates the profile and leaves +// content decoding to this package. +func newHTTPClient(profile TLSProfile) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + + // Decoding is ours: the transport would expand a bomb before any budget + // could see it. + transport.DisableCompression = true + transport.ForceAttemptHTTP2 = true + transport.TLSClientConfig = &tls.Config{ + CipherSuites: profile.CipherSuites, + CurvePreferences: profile.CurvePreferences, + MinVersion: profile.MinVersion, + MaxVersion: profile.MaxVersion, + } + return &http.Client{Transport: transport, CheckRedirect: bankRedirectCookies} +} + +// requestOptions are the knobs of a single request. +type requestOptions struct { + params url.Values + headers map[string]string + data url.Values + allowedErrorCodes []int + timeout time.Duration + // maxResponseBytes rejects a body that expands past this many bytes. + maxResponseBytes int + // maxDownloadBytes rejects a body larger than this on the wire. Separate + // because compression can grow incompressible data. + maxDownloadBytes int +} + +// Response is a decoded FlightRadar24 response. +type Response struct { + URL string + StatusCode int + Status string + Header http.Header + // Body is the body after Content-Encoding has been undone. + Body []byte + // Cookies are the name/value pairs the response set. + Cookies map[string]string +} + +// IsJSON reports whether the response announced a JSON body. +func (r *Response) IsJSON() bool { + return strings.Contains(r.Header.Get("Content-Type"), "application/json") +} + +// JSON parses the body as a JSON object. +func (r *Response) JSON() (map[string]any, error) { + if !r.IsJSON() { + return nil, fmt.Errorf("%w: expected JSON response from %s, got %q", + ErrFlightRadar, r.URL, r.Header.Get("Content-Type")) + } + + var content map[string]any + + if err := json.Unmarshal(bytes.TrimPrefix(r.Body, []byte("\xef\xbb\xbf")), &content); err != nil { + return nil, fmt.Errorf("%w: could not parse the JSON body of %s: %w", ErrFlightRadar, r.URL, err) + } + return content, nil +} + +// Text returns the body as a string, dropping a leading byte-order mark. +func (r *Response) Text() string { + return string(bytes.TrimPrefix(r.Body, []byte("\xef\xbb\xbf"))) +} + +// apiClient owns the persistent session (cookie jar, TLS fingerprint) so the +// rest of the package never deals with those concerns. +type apiClient struct { + httpClient *http.Client + jar *cookieJar + retry *RetryPolicy +} + +func newAPIClient(httpClient *http.Client, retry *RetryPolicy) *apiClient { + // Copied so a caller's client is left alone, and so a client that follows + // redirects blindly still banks the cookies handed out on the way. + client := *httpClient + + if client.CheckRedirect == nil { + client.CheckRedirect = bankRedirectCookies + } + + // This package renders the Cookie header itself. A jar on the caller's + // client would append a second copy of every cookie to that same header. + client.Jar = nil + return &apiClient{httpClient: &client, jar: newCookieJar(), retry: retry} +} + +// request makes a request through the shared session, sending the cookies in +// scope for the URL and banking the ones the response returns. +func (c *apiClient) request(ctx context.Context, target string, options requestOptions) (*Response, error) { + return runWithRetry(ctx, c.retry, func() (*Response, error) { + return c.do(ctx, target, options, c.jar) + }) +} + +// requestStandalone makes a request that does not touch the shared cookie jar, +// so concurrent fan-outs cannot race Set-Cookie headers onto it. +func (c *apiClient) requestStandalone(ctx context.Context, target string, options requestOptions) (*Response, error) { + return runWithRetry(ctx, c.retry, func() (*Response, error) { + return c.do(ctx, target, options, nil) + }) +} + +func (c *apiClient) getCookie(name string) (string, bool) { return c.jar.get(name) } +func (c *apiClient) clearCookies() { c.jar.clear() } +func (c *apiClient) deleteCookie(name string) { c.jar.delete(name) } + +func (c *apiClient) do(ctx context.Context, target string, options requestOptions, jar *cookieJar) (*Response, error) { + maxResponseBytes := options.maxResponseBytes + + if maxResponseBytes == 0 { + maxResponseBytes = MaxResponseBytes + } + if maxResponseBytes < 1 { + return nil, fmt.Errorf("%w: maxResponseBytes must be >= 1", ErrFlightRadar) + } + + maxDownloadBytes := options.maxDownloadBytes + + if maxDownloadBytes == 0 { + maxDownloadBytes = maxResponseBytes + } + if maxDownloadBytes < 1 { + return nil, fmt.Errorf("%w: maxDownloadBytes must be >= 1", ErrFlightRadar) + } + + if len(options.params) > 0 { + target += "?" + options.params.Encode() + } + + parsed, err := url.Parse(target) + + if err != nil { + return nil, fmt.Errorf("%w: invalid URL %q: %w", ErrFlightRadar, target, err) + } + + if jar != nil { + ctx = context.WithValue(ctx, jarKey{}, jar) + } + + timeout := options.timeout + + if timeout == 0 { + timeout = DefaultTimeout + } + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + method := http.MethodGet + var body io.Reader + + if options.data != nil { + method = http.MethodPost + body = strings.NewReader(options.data.Encode()) + } + + request, err := http.NewRequestWithContext(ctx, method, target, body) + + if err != nil { + return nil, err + } + + for name, value := range options.headers { + request.Header.Set(name, value) + } + if request.Header.Get("accept-encoding") == "" { + request.Header.Set("accept-encoding", supportedEncodings) + } + if method == http.MethodPost { + request.Header.Set("content-type", "application/x-www-form-urlencoded") + } + if jar != nil { + if header := jar.header(parsed); header != "" { + request.Header.Set("cookie", header) + } + } + + response, err := c.httpClient.Do(request) + + if err != nil { + return nil, err + } + defer response.Body.Close() + + received, err := io.ReadAll(io.LimitReader(response.Body, int64(maxDownloadBytes)+1)) + + // Banked even on failure: the response that blocks a request is the one + // that hands out the cookie needed to pass next time. Credited to the host + // that answered, which after a redirect is not the host that was asked. + finalURL := parsed + + if response.Request != nil && response.Request.URL != nil { + finalURL = response.Request.URL + } + if jar != nil { + jar.store(finalURL, response.Header.Values("Set-Cookie")) + } + + if err != nil { + return nil, err + } + if len(received) > maxDownloadBytes { + return nil, limitError("response body from %s is larger than the %d byte download limit", + finalURL, maxDownloadBytes) + } + + content, err := decodeBody(received, response, finalURL.String(), maxResponseBytes) + + if err != nil { + return nil, err + } + + // The decoders enforce the budget as they expand, but an identity body + // never reaches one. + if len(content) > maxResponseBytes { + return nil, limitError("response body from %s is %d bytes, past the %d byte limit", + finalURL, len(content), maxResponseBytes) + } + + result := &Response{ + URL: finalURL.String(), + StatusCode: response.StatusCode, + Status: response.Status, + Header: response.Header, + Body: content, + Cookies: responseCookies(response.Header.Values("Set-Cookie")), + } + + if isAllowed(response.StatusCode, options.allowedErrorCodes) { + return result, nil + } + + // Cloudflare detection only when the caller did not opt in to this status + // code: getAirlineLogo/getCountryFlag allow 403 to mean "asset not found". + if isCloudflareBlock(response.StatusCode, response.Header) { + response.Body = io.NopCloser(bytes.NewReader(content)) + + return nil, &CloudflareError{ + Message: "blocked by Cloudflare. Perhaps you are making too many calls, " + + "or the TLS impersonation needs to be updated", + Response: response, + Body: content, + } + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, &StatusError{ + StatusCode: response.StatusCode, + Status: response.Status, + URL: finalURL.String(), + Body: content, + } + } + return result, nil +} + +func isAllowed(statusCode int, allowed []int) bool { + for _, code := range allowed { + if code == statusCode { + return true + } + } + return false +} + +// isCloudflareBlock detects Cloudflare-level blocks. +// +// FR24 fronts the public site with Cloudflare, so a "Server: cloudflare" header +// is present on every response, including legitimate 403s from the FR24 origin +// (e.g. premium-only endpoints on a free account). Only signals Cloudflare sets +// when its own bot management acted are trusted: HTTP 520, and a 403 carrying +// cf-mitigated. +func isCloudflareBlock(statusCode int, headers http.Header) bool { + if statusCode == 520 { + return true + } + if statusCode != 403 { + return false + } + return headers.Get("cf-mitigated") != "" +} + +// responseCookies renders the Set-Cookie headers as name/value pairs. +func responseCookies(headers []string) map[string]string { + cookies := make(map[string]string, len(headers)) + + for _, header := range headers { + pair := strings.Split(header, ";")[0] + separator := strings.Index(pair, "=") + + // Split on the first "=" only, so base64 padding survives. + if separator > 0 { + cookies[strings.TrimSpace(pair[:separator])] = strings.TrimSpace(pair[separator+1:]) + } + } + return cookies +} + +// decodeBody undoes the Content-Encoding a response arrived with. The header +// may stack encodings ("gzip, br" means gzip then brotli), so they are undone in +// reverse. A body that will not decode is returned as received, which is what a +// transport that decoded it after all leaves behind. +func decodeBody(content []byte, response *http.Response, target string, limit int) ([]byte, error) { + var applied []string + + for _, token := range strings.Split(response.Header.Get("Content-Encoding"), ",") { + token = strings.ToLower(strings.TrimSpace(token)) + + if token != "" && token != "identity" { + applied = append(applied, token) + } + } + if len(applied) == 0 { + return content, nil + } + + chain := make([]func([]byte, int) ([]byte, error), 0, len(applied)) + + for _, token := range applied { + decode, ok := decoders[token] + + if !ok { + log().Warn("no decoder for Content-Encoding; returning the body as received", + "encoding", token, "url", target) + return content, nil + } + chain = append(chain, decode) + } + + decoded := content + + // Undone in reverse: "gzip, br" means gzip was applied first, brotli last. + for index := len(chain) - 1; index >= 0; index-- { + failedAt := applied[index] + next, err := chain[index](decoded, limit) + + if err != nil { + if errors.Is(err, ErrDecompressionLimit) { + return nil, err + } + + // Back to the bytes as received: a chain that failed halfway leaves + // a half-decoded intermediate. + logDecodeFailure(content, response, target, failedAt, err) + return content, nil + } + decoded = next + } + return decoded, nil +} + +// logDecodeFailure warns unless the body looks like the transport decoded it +// already, in which case there is nothing for a caller to act on. +func logDecodeFailure(content []byte, response *http.Response, target, failedAt string, cause error) { + contentType := response.Header.Get("Content-Type") + transportDecoded := false + + switch { + case strings.HasPrefix(contentType, "application/json"), strings.HasPrefix(contentType, "text/"): + // Undecodable text is genuinely broken; a body that reads as UTF-8 was + // most likely decoded by the transport already. + transportDecoded = utf8.Valid(content) + default: + // A body still carrying the gzip magic was not decoded by anyone, so + // the decoder failing on it is a real corruption, not a double decode. + transportDecoded = !(failedAt == "gzip" && bytes.HasPrefix(content, gzipMagic)) + } + + level := slog.LevelWarn + + if transportDecoded { + level = slog.LevelDebug + } + log().Log(context.Background(), level, + "failed to decode Content-Encoding; assuming the body arrived already decoded", + "encoding", failedAt, "url", target, "error", cause) +} diff --git a/go/flightradarapi/request_test.go b/go/flightradarapi/request_test.go new file mode 100644 index 0000000..73bfbad --- /dev/null +++ b/go/flightradarapi/request_test.go @@ -0,0 +1,1250 @@ +package flightradarapi + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "compress/zlib" + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/andybalholm/brotli" +) + +// The request suite is split the way the Python and Node.js ones are: the +// policy tests cover rules that survive a transport rewrite (retry semantics, +// Cloudflare detection, the error taxonomy), the transport tests cover +// adapter-shaped behavior (method dispatch, query encoding, content decoding). + +func testClient() *apiClient { + return newAPIClient(newHTTPClient(Chrome136Profile()), nil) +} + +// serve starts a test server and returns a client pointed at nothing in +// particular: the URL is handed to each call. +func serve(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return server +} + +func gzipBytes(t *testing.T, data []byte) []byte { + t.Helper() + var buffer bytes.Buffer + writer := gzip.NewWriter(&buffer) + + if _, err := writer.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func zlibBytes(t *testing.T, data []byte) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zlib.NewWriter(&buffer) + + if _, err := writer.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func rawDeflateBytes(t *testing.T, data []byte) []byte { + t.Helper() + var buffer bytes.Buffer + writer, err := flate.NewWriter(&buffer, flate.DefaultCompression) + + if err != nil { + t.Fatal(err) + } + if _, err := writer.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func brotliBytes(t *testing.T, data []byte) []byte { + t.Helper() + var buffer bytes.Buffer + writer := brotli.NewWriter(&buffer) + + if _, err := writer.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +// --- transport --- + +func TestRequestSendsGETWithoutData(t *testing.T) { + var method string + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + method = r.Method + w.Write([]byte("ok")) + }) + + if _, err := testClient().request(context.Background(), server.URL, requestOptions{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if method != http.MethodGet { + t.Errorf("got %s, want GET", method) + } +} + +func TestRequestSendsPOSTWithFormData(t *testing.T) { + var method, body, contentType string + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + received, _ := io.ReadAll(r.Body) + method, body, contentType = r.Method, string(received), r.Header.Get("Content-Type") + w.Write([]byte("ok")) + }) + + data := url.Values{} + data.Set("email", "user@example.com") + data.Set("password", "secret") + + if _, err := testClient().request(context.Background(), server.URL, requestOptions{data: data}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if method != http.MethodPost { + t.Errorf("got %s, want POST", method) + } + if !strings.Contains(body, "email=user%40example.com") || !strings.Contains(body, "password=secret") { + t.Errorf("got body %q", body) + } + if contentType != "application/x-www-form-urlencoded" { + t.Errorf("got content type %q", contentType) + } +} + +func TestRequestEncodesParamsIntoTheQuery(t *testing.T) { + var query string + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + query = r.URL.RawQuery + w.Write([]byte("ok")) + }) + + params := url.Values{} + params.Set("bounds", "75.78,-75.78,-427.56,427.56") + params.Set("limit", "5000") + + if _, err := testClient().request(context.Background(), server.URL, requestOptions{params: params}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + parsed, err := url.ParseQuery(query) + + if err != nil { + t.Fatal(err) + } + if parsed.Get("bounds") != "75.78,-75.78,-427.56,427.56" || parsed.Get("limit") != "5000" { + t.Errorf("got query %q", query) + } +} + +func TestRequestAsksOnlyForEncodingsItCanDecode(t *testing.T) { + var accept string + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + accept = r.Header.Get("Accept-Encoding") + w.Write([]byte("ok")) + }) + + if _, err := testClient().request(context.Background(), server.URL, requestOptions{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if accept != supportedEncodings { + t.Errorf("got %q, want %q", accept, supportedEncodings) + } + + // zstd would arrive as bytes nothing here can read. + if strings.Contains(accept, "zstd") { + t.Error("zstd must not be advertised") + } +} + +func TestRequestKeepsAnExplicitAcceptEncoding(t *testing.T) { + var accept string + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + accept = r.Header.Get("Accept-Encoding") + w.Write([]byte("ok")) + }) + + _, err := testClient().request(context.Background(), server.URL, requestOptions{ + headers: map[string]string{"accept-encoding": "gzip"}, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if accept != "gzip" { + t.Errorf("got %q, want gzip", accept) + } +} + +func TestRequestSendsTheGivenHeaders(t *testing.T) { + var agent, accept string + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + agent, accept = r.Header.Get("User-Agent"), r.Header.Get("Accept") + w.Write([]byte("ok")) + }) + + _, err := testClient().request(context.Background(), server.URL, requestOptions{headers: jsonHeaders}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(agent, "Chrome/136") || accept != "application/json" { + t.Errorf("got user agent %q accept %q", agent, accept) + } +} + +func TestRequestDecodesEveryAdvertisedEncoding(t *testing.T) { + payload := []byte(`{"full_count": 12345}`) + + cases := []struct { + encoding string + body func(*testing.T, []byte) []byte + }{ + {"gzip", gzipBytes}, + {"deflate", zlibBytes}, + {"deflate", rawDeflateBytes}, + {"br", brotliBytes}, + } + + for _, testCase := range cases { + t.Run(testCase.encoding, func(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", testCase.encoding) + w.Header().Set("Content-Type", "application/json") + w.Write(testCase.body(t, payload)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(response.Body, payload) { + t.Errorf("got %q, want %q", response.Body, payload) + } + }) + } +} + +func TestRequestDecodesStackedEncodings(t *testing.T) { + payload := []byte("stacked body") + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip, br") + w.Write(brotliBytes(t, gzipBytes(t, payload))) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(response.Body, payload) { + t.Errorf("got %q, want %q", response.Body, payload) + } +} + +func TestRequestDecodesEncodingTokensCaseInsensitively(t *testing.T) { + payload := []byte("upper case token") + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", " GZIP ") + w.Write(gzipBytes(t, payload)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(response.Body, payload) { + t.Errorf("got %q, want %q", response.Body, payload) + } +} + +func TestRequestDecodesAMultiMemberGzipBody(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip") + w.Write(append(gzipBytes(t, []byte("first ")), gzipBytes(t, []byte("second"))...)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(response.Body) != "first second" { + t.Errorf("got %q, want \"first second\"", response.Body) + } +} + +func TestRequestToleratesTrailingPaddingAfterAGzipBody(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip") + w.Write(append(gzipBytes(t, []byte("padded")), 0, 0, 0)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(response.Body) != "padded" { + t.Errorf("got %q, want \"padded\"", response.Body) + } +} + +func TestRequestReturnsTheBodyWhenTheTransportAlreadyDecodedIt(t *testing.T) { + // The header claims an encoding the body does not carry, which is what a + // transport that decoded it leaves behind. + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"already": "decoded"}`)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(response.Body) != `{"already": "decoded"}` { + t.Errorf("got %q", response.Body) + } +} + +func TestRequestReturnsTheBodyForAnEncodingWithNoDecoder(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "zstd") + w.Write([]byte("opaque")) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(response.Body) != "opaque" { + t.Errorf("got %q, want the body as received", response.Body) + } +} + +func TestRequestRefusesABodyThatExpandsPastTheBudget(t *testing.T) { + bomb := gzipBytes(t, bytes.Repeat([]byte("A"), 1<<20)) + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip") + w.Write(bomb) + }) + + _, err := testClient().request(context.Background(), server.URL, requestOptions{ + maxResponseBytes: 1024, + }) + + if !errors.Is(err, ErrDecompressionLimit) { + t.Errorf("got %v, want a decompression limit error", err) + } +} + +func TestRequestRefusesABodyLargerThanTheDownloadBudget(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Write(bytes.Repeat([]byte("A"), 4096)) + }) + + _, err := testClient().request(context.Background(), server.URL, requestOptions{ + maxDownloadBytes: 1024, + maxResponseBytes: 1 << 20, + }) + + if !errors.Is(err, ErrDecompressionLimit) { + t.Errorf("got %v, want a decompression limit error", err) + } +} + +func TestRequestRefusesAnIdentityBodyPastTheBudget(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Write(bytes.Repeat([]byte("A"), 4096)) + }) + + _, err := testClient().request(context.Background(), server.URL, requestOptions{maxResponseBytes: 1024}) + + if !errors.Is(err, ErrDecompressionLimit) { + t.Errorf("got %v, want a decompression limit error", err) + } +} + +func TestRequestAcceptsABodyExactlyAtTheBudget(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Write(bytes.Repeat([]byte("A"), 1024)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{ + maxResponseBytes: 1024, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(response.Body) != 1024 { + t.Errorf("got %d bytes, want 1024", len(response.Body)) + } +} + +func TestResponseJSONNeedsAJSONContentType(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`{"a": 1}`)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := response.JSON(); err == nil { + t.Error("expected an error for a non-JSON content type") + } +} + +func TestResponseJSONParsesAJSONBody(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(`{"full_count": 12345}`)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + content, err := response.JSON() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count, _ := content["full_count"].(float64); count != 12345 { + t.Errorf("got %v, want 12345", content["full_count"]) + } +} + +func TestResponseTextDropsAByteOrderMark(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Write(append([]byte("\xef\xbb\xbf"), []byte("body")...)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if response.Text() != "body" { + t.Errorf("got %q, want body", response.Text()) + } +} + +func TestRequestBanksAndReplaysCookies(t *testing.T) { + var received string + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + received = r.Header.Get("Cookie") + w.Header().Add("Set-Cookie", "_frPl=token; Path=/") + w.Write([]byte("ok")) + }) + + client := testClient() + + if _, err := client.request(context.Background(), server.URL, requestOptions{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if value, ok := client.getCookie("_frPl"); !ok || value != "token" { + t.Errorf("got cookie %q, want token", value) + } + if _, err := client.request(context.Background(), server.URL, requestOptions{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if received != "_frPl=token" { + t.Errorf("got %q, want the cookie replayed", received) + } +} + +func TestRequestBanksCookiesFromABlockedResponse(t *testing.T) { + // A Cloudflare 403 is exactly the response that hands out cf_clearance. + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("cf-mitigated", "challenge") + w.Header().Add("Set-Cookie", "cf_clearance=pass; Path=/") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("challenge")) + }) + + client := testClient() + _, err := client.request(context.Background(), server.URL, requestOptions{}) + + if !errors.Is(err, ErrCloudflare) { + t.Fatalf("got %v, want a Cloudflare error", err) + } + if value, ok := client.getCookie("cf_clearance"); !ok || value != "pass" { + t.Errorf("got cookie %q, want the challenge cookie banked", value) + } +} + +func TestRequestStandaloneLeavesTheJarAlone(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Cookie") != "" { + t.Errorf("standalone request sent %q", r.Header.Get("Cookie")) + } + w.Header().Add("Set-Cookie", "session=1; Path=/") + w.Write([]byte("ok")) + }) + + client := testClient() + client.jar.store(mustURL(t, server.URL), []string{"existing=1; Path=/"}) + + if _, err := client.requestStandalone(context.Background(), server.URL, requestOptions{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := client.getCookie("session"); ok { + t.Error("a standalone request must not bank cookies") + } +} + +func TestRequestExposesTheResponseCookies(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Set-Cookie", "token=YWJjZA==; Path=/") + w.Write([]byte("ok")) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if response.Cookies["token"] != "YWJjZA==" { + t.Errorf("got %q, want the padding kept", response.Cookies["token"]) + } +} + +// --- policy --- + +func TestCloudflareBlockIsDetectedOnlyOnItsOwnSignals(t *testing.T) { + cases := []struct { + name string + statusCode int + headers map[string]string + cloudflare bool + }{ + {"520 from cloudflare", 520, nil, true}, + {"403 with cf-mitigated", 403, map[string]string{"cf-mitigated": "challenge"}, true}, + // A premium-only endpoint on a free account answers like this. + {"403 from the origin", 403, map[string]string{"server": "cloudflare"}, false}, + {"500 from the origin", 500, nil, false}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + for name, value := range testCase.headers { + w.Header().Set(name, value) + } + w.WriteHeader(testCase.statusCode) + }) + + _, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err == nil { + t.Fatal("expected an error") + } + if errors.Is(err, ErrCloudflare) != testCase.cloudflare { + t.Errorf("got %v, want cloudflare=%v", err, testCase.cloudflare) + } + }) + } +} + +func TestCloudflareErrorCarriesTheChallengePage(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("cf-mitigated", "challenge") + w.Header().Set("Content-Encoding", "gzip") + w.WriteHeader(http.StatusForbidden) + w.Write(gzipBytes(t, []byte("Attention Required"))) + }) + + _, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + var cloudflareErr *CloudflareError + + if !errors.As(err, &cloudflareErr) { + t.Fatalf("got %v, want a CloudflareError", err) + } + if !strings.Contains(string(cloudflareErr.Body), "Attention Required") { + t.Errorf("got body %q, want the decoded challenge page", cloudflareErr.Body) + } + if cloudflareErr.Response == nil || cloudflareErr.Response.StatusCode != http.StatusForbidden { + t.Error("the blocked response must be carried on the error") + } +} + +func TestAllowedErrorCodesSuppressBothChecks(t *testing.T) { + // GetAirlineLogo and GetCountryFlag allow 403 to mean "asset not found". + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("cf-mitigated", "challenge") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("no asset")) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{ + allowedErrorCodes: []int{403, 404}, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if response.StatusCode != http.StatusForbidden { + t.Errorf("got %d, want 403", response.StatusCode) + } +} + +func TestStatusErrorCarriesTheStatusAndURL(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + _, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + var statusErr *StatusError + + if !errors.As(err, &statusErr) { + t.Fatalf("got %v, want a StatusError", err) + } + if statusErr.StatusCode != 500 || !strings.HasPrefix(statusErr.URL, server.URL) { + t.Errorf("got %d for %q", statusErr.StatusCode, statusErr.URL) + } + if !errors.Is(err, ErrFlightRadar) { + t.Error("every error must match ErrFlightRadar") + } +} + +func TestRetryRecoversFromATransientBlock(t *testing.T) { + var attempts atomic.Int32 + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + if attempts.Add(1) < 3 { + w.Header().Set("cf-mitigated", "challenge") + w.WriteHeader(http.StatusForbidden) + return + } + w.Write([]byte("ok")) + }) + + client := newAPIClient(newHTTPClient(Chrome136Profile()), &RetryPolicy{ + MaxAttempts: 3, BaseDelay: time.Millisecond, + }) + + response, err := client.request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(response.Body) != "ok" { + t.Errorf("got %q, want ok", response.Body) + } + if attempts.Load() != 3 { + t.Errorf("got %d attempts, want 3", attempts.Load()) + } +} + +func TestRetryGivesUpAfterMaxAttempts(t *testing.T) { + var attempts atomic.Int32 + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(520) + }) + + client := newAPIClient(newHTTPClient(Chrome136Profile()), &RetryPolicy{ + MaxAttempts: 2, BaseDelay: time.Millisecond, + }) + + if _, err := client.request(context.Background(), server.URL, requestOptions{}); !errors.Is(err, ErrCloudflare) { + t.Errorf("got %v, want the last Cloudflare error", err) + } + if attempts.Load() != 2 { + t.Errorf("got %d attempts, want 2", attempts.Load()) + } +} + +func TestRetryLeavesANonTransientErrorAlone(t *testing.T) { + var attempts atomic.Int32 + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(http.StatusInternalServerError) + }) + + client := newAPIClient(newHTTPClient(Chrome136Profile()), &RetryPolicy{ + MaxAttempts: 3, BaseDelay: time.Millisecond, + }) + + if _, err := client.request(context.Background(), server.URL, requestOptions{}); err == nil { + t.Fatal("expected an error") + } + if attempts.Load() != 1 { + t.Errorf("got %d attempts, want 1: a 500 is the origin's answer, not a transient failure", + attempts.Load()) + } +} + +func TestRetryPolicyBackoffGrowsAndIsCapped(t *testing.T) { + policy := &RetryPolicy{MaxAttempts: 5, BaseDelay: time.Second, MaxDelay: 4 * time.Second} + + for attempt, expected := range map[int]time.Duration{0: time.Second, 1: 2 * time.Second, 5: 4 * time.Second} { + if got := policy.SleepFor(attempt); got != expected { + t.Errorf("attempt %d: got %v, want %v", attempt, got, expected) + } + } +} + +func TestRetryPolicyJitterStaysInRange(t *testing.T) { + policy := &RetryPolicy{MaxAttempts: 2, BaseDelay: time.Second, MaxDelay: time.Second, Jitter: time.Second} + + for range 20 { + delay := policy.SleepFor(0) + + if delay < time.Second || delay > 2*time.Second { + t.Fatalf("got %v, want between 1s and 2s", delay) + } + } +} + +func TestNewRetryPolicyRejectsAnImpossibleAttemptCount(t *testing.T) { + if _, err := NewRetryPolicy(0); err == nil { + t.Error("expected an error for MaxAttempts below 1") + } +} + +func TestRequestHonoursACancelledContext(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := testClient().request(ctx, server.URL, requestOptions{}); !errors.Is(err, context.Canceled) { + t.Errorf("got %v, want a cancelled context", err) + } +} + +func TestRequestTimesOut(t *testing.T) { + release := make(chan struct{}) + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + <-release + }) + t.Cleanup(func() { close(release) }) + + _, err := testClient().request(context.Background(), server.URL, requestOptions{ + timeout: 50 * time.Millisecond, + }) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("got %v, want a deadline error", err) + } +} + +func TestIsTransientClassifiesFailures(t *testing.T) { + cases := []struct { + name string + err error + transient bool + }{ + {"cloudflare", &CloudflareError{Message: "blocked"}, true}, + {"timeout", &url.Error{Op: "Get", Err: context.DeadlineExceeded}, true}, + {"network", &url.Error{Op: "Get", Err: errors.New("connection reset")}, true}, + {"cancelled", context.Canceled, false}, + {"decompression limit", &DecompressionLimitError{Message: "too big"}, false}, + {"status", &StatusError{StatusCode: 500}, false}, + {"nothing", nil, false}, + } + + for _, testCase := range cases { + if got := isTransient(testCase.err); got != testCase.transient { + t.Errorf("%s: got %v, want %v", testCase.name, got, testCase.transient) + } + } +} + +func TestDecompressorsRejectATruncatedBody(t *testing.T) { + payload := bytes.Repeat([]byte("A"), 4096) + + cases := map[string][]byte{ + "gzip": gzipBytes(t, payload), + "deflate": zlibBytes(t, payload), + "br": brotliBytes(t, payload), + } + + for encoding, body := range cases { + t.Run(encoding, func(t *testing.T) { + if _, err := decoders[encoding](body[:len(body)/2], MaxResponseBytes); err == nil { + t.Error("a truncated body must not pass as whole") + } + }) + } +} + +func TestRequestBanksCookiesHandedOutOnARedirectHop(t *testing.T) { + // net/http follows redirects internally, so the hop that sets the cookie is + // never seen by the caller. + var finalCookie string + mux := http.NewServeMux() + mux.HandleFunc("/start", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Set-Cookie", "hop=abc; Path=/") + http.Redirect(w, r, "/final", http.StatusFound) + }) + mux.HandleFunc("/final", func(w http.ResponseWriter, r *http.Request) { + finalCookie = r.Header.Get("Cookie") + w.Write([]byte("ok")) + }) + + client := testClient() + _, err := client.request(context.Background(), serve(t, mux.ServeHTTP).URL+"/start", requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if value, ok := client.getCookie("hop"); !ok || value != "abc" { + t.Errorf("got cookie %q, want the redirect hop's cookie banked", value) + } + if finalCookie != "hop=abc" { + t.Errorf("got %q on the final hop, want the cookie replayed", finalCookie) + } +} + +func TestRequestSendsInScopeCookiesAfterACrossHostRedirect(t *testing.T) { + // Go strips the Cookie header on a cross-host redirect; a domain-scoped + // cookie still belongs on the new host. + var received string + target := serve(t, func(w http.ResponseWriter, r *http.Request) { + received = r.Header.Get("Cookie") + w.Write([]byte("ok")) + }) + origin := serve(t, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/final", http.StatusFound) + }) + + client := testClient() + client.jar.store(mustURL(t, target.URL), []string{"session=1; Path=/"}) + + if _, err := client.request(context.Background(), origin.URL, requestOptions{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if received != "session=1" { + t.Errorf("got %q, want the cookie of the host being redirected to", received) + } +} + +func TestRequestStopsAfterTooManyRedirects(t *testing.T) { + var server *httptest.Server + server = serve(t, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, server.URL+"/again", http.StatusFound) + }) + + if _, err := testClient().request(context.Background(), server.URL, requestOptions{}); err == nil { + t.Error("expected an error for a redirect loop") + } +} + +func TestRequestStandaloneStillFollowsRedirects(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/start", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Set-Cookie", "hop=abc; Path=/") + http.Redirect(w, r, "/final", http.StatusFound) + }) + mux.HandleFunc("/final", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + client := testClient() + response, err := client.requestStandalone( + context.Background(), serve(t, mux.ServeHTTP).URL+"/start", requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(response.Body) != "ok" { + t.Errorf("got %q, want the body of the final hop", response.Body) + } + if _, ok := client.getCookie("hop"); ok { + t.Error("a standalone request must not bank a redirect hop's cookies either") + } +} + +func TestGivenHTTPClientIsLeftUnmodified(t *testing.T) { + given := &http.Client{Transport: newHTTPClient(Chrome136Profile()).Transport} + newAPIClient(given, nil) + + if given.CheckRedirect != nil { + t.Error("the caller's client must not be modified") + } +} + +func TestGivenRedirectHandlerIsKept(t *testing.T) { + var called bool + given := &http.Client{ + Transport: newHTTPClient(Chrome136Profile()).Transport, + CheckRedirect: func(request *http.Request, via []*http.Request) error { + called = true + return http.ErrUseLastResponse + }, + } + + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/final", http.StatusFound) + }) + + response, err := newAPIClient(given, nil).request(context.Background(), server.URL, requestOptions{ + allowedErrorCodes: []int{302}, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !called || response.StatusCode != http.StatusFound { + t.Errorf("called=%v status=%d, want the caller's handler to decide", called, response.StatusCode) + } +} + +// --- decoder budget and integrity, mirroring the Python transport suite --- + +func TestEveryDecoderRefusesABomb(t *testing.T) { + payload := bytes.Repeat([]byte("A"), 1<<20) + + cases := map[string][]byte{ + "gzip": gzipBytes(t, payload), + "deflate": zlibBytes(t, payload), + "br": brotliBytes(t, payload), + } + + for encoding, body := range cases { + t.Run(encoding, func(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", encoding) + w.Write(body) + }) + + _, err := testClient().request(context.Background(), server.URL, requestOptions{ + maxResponseBytes: 1024, + }) + + if !errors.Is(err, ErrDecompressionLimit) { + t.Errorf("got %v, want a decompression limit error", err) + } + }) + } +} + +func TestEveryAdvertisedEncodingHasADecoder(t *testing.T) { + // The header is derived from the table, so this catches the const drifting. + for _, token := range strings.Split(supportedEncodings, ",") { + token = strings.TrimSpace(token) + + if _, ok := decoders[token]; !ok { + t.Errorf("%q is advertised with no decoder behind it", token) + } + } + + for token := range decoders { + if !strings.Contains(supportedEncodings, token) { + t.Errorf("%q has a decoder that is never advertised", token) + } + } +} + +func TestAnEmptyBodyIsNotMistakenForABomb(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip") + w.Write(gzipBytes(t, nil)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{ + maxResponseBytes: 1024, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(response.Body) != 0 { + t.Errorf("got %q, want an empty body", response.Body) + } +} + +func TestANonsensicalBudgetIsRejectedAtTheCall(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + cases := map[string]requestOptions{ + "negative response budget": {maxResponseBytes: -1}, + "negative download budget": {maxDownloadBytes: -1}, + } + + for name, options := range cases { + if _, err := testClient().request(context.Background(), server.URL, options); err == nil { + t.Errorf("%s: expected an error", name) + } + } +} + +func TestTheDownloadBudgetDefaultsToTheExpansionBudget(t *testing.T) { + // 4096 uncompressed bytes on the wire, and no separate download bound. + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Write(bytes.Repeat([]byte("A"), 4096)) + }) + + _, err := testClient().request(context.Background(), server.URL, requestOptions{ + maxResponseBytes: 1024, + }) + + if !errors.Is(err, ErrDecompressionLimit) { + t.Errorf("got %v, want the response budget to bound the download too", err) + } +} + +func TestTheDownloadBudgetCanBeLooserThanTheExpansionBudget(t *testing.T) { + // Compression can grow incompressible data, so a body that expands to just + // under the budget may still arrive slightly over it. + payload := bytes.Repeat([]byte("A"), 2048) + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip") + w.Write(gzipBytes(t, payload)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{ + maxResponseBytes: 4096, + maxDownloadBytes: 1 << 20, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(response.Body) != len(payload) { + t.Errorf("got %d bytes, want %d", len(response.Body), len(payload)) + } +} + +func TestABodyThatIsNotDeflateRaisesRatherThanInflatingToGarbage(t *testing.T) { + // Raw deflate carries no header and no checksum, so a body that is not + // deflate at all can still inflate to plausible bytes. + if _, err := decompressDeflate([]byte("this is not a deflate stream at all"), MaxResponseBytes); err == nil { + t.Error("expected an error rather than garbage") + } +} + +func TestTrailingBytesDoNotBreakAZlibWrappedBody(t *testing.T) { + payload := []byte("a zlib body with padding after it") + body := append(zlibBytes(t, payload), 0, 0, 0) + + decoded, err := decompressDeflate(body, MaxResponseBytes) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(decoded, payload) { + t.Errorf("got %q, want %q", decoded, payload) + } +} + +func TestAChainThatFailsMidwayFallsBackToTheBodyAsReceived(t *testing.T) { + // The outer brotli layer decodes; the inner gzip layer never does. + inner := []byte("not gzip at all") + received := brotliBytes(t, inner) + + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip, br") + w.Write(received) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(response.Body, received) { + t.Errorf("got %q, want the body exactly as received, not the half-decoded intermediate", + response.Body) + } +} + +func TestAnUndecodableEncodingWarns(t *testing.T) { + var logged bytes.Buffer + SetLogger(slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { SetLogger(nil) }) + + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "zstd") + w.Write([]byte("opaque")) + }) + + if _, err := testClient().request(context.Background(), server.URL, requestOptions{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := logged.String() + + if !strings.Contains(output, "level=WARN") || !strings.Contains(output, "zstd") { + t.Errorf("got %q, want a warning naming the encoding", output) + } +} + +func TestTheStandalonePathDecodesAndBoundsToo(t *testing.T) { + payload := []byte(`{"decoded": true}`) + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "br") + w.Header().Set("Content-Type", "application/json") + w.Write(brotliBytes(t, payload)) + }) + + client := testClient() + response, err := client.requestStandalone(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(response.Body, payload) { + t.Errorf("got %q, want %q", response.Body, payload) + } + + _, err = client.requestStandalone(context.Background(), server.URL, requestOptions{ + maxResponseBytes: 4, + }) + + if !errors.Is(err, ErrDecompressionLimit) { + t.Errorf("got %v, want the standalone path to enforce the budget too", err) + } +} + +func TestTheTransportNeverDecodesForUs(t *testing.T) { + // Owning the decoding is what makes the budget enforceable. + transport, ok := newHTTPClient(Chrome136Profile()).Transport.(*http.Transport) + + if !ok { + t.Fatal("expected an *http.Transport") + } + if !transport.DisableCompression { + t.Error("the transport must leave content decoding to this package") + } +} + +// --- retry policy edges --- + +func TestRetryPolicyValidationReportsNegativeTiming(t *testing.T) { + // The Python and Node.js ports raise from the RetryPolicy constructor, which + // is where this port reports it too. + cases := map[string]*RetryPolicy{ + "negative base delay": {MaxAttempts: 2, BaseDelay: -time.Second}, + "negative max delay": {MaxAttempts: 2, MaxDelay: -time.Second}, + "negative jitter": {MaxAttempts: 2, Jitter: -time.Second}, + } + + for name, policy := range cases { + if err := policy.validate(); err == nil { + t.Errorf("%s: expected an error", name) + } + } +} + +func TestAZeroMaxDelayCapsNothing(t *testing.T) { + // Every field is public, so the zero value has to mean something sane: a + // policy built as a struct literal used to back off for zero seconds. + policy := &RetryPolicy{MaxAttempts: 5, BaseDelay: 2 * time.Second} + + for attempt, expected := range map[int]time.Duration{0: 2 * time.Second, 2: 8 * time.Second} { + if got := policy.SleepFor(attempt); got != expected { + t.Errorf("attempt %d: got %v, want %v", attempt, got, expected) + } + } +} + +func TestANegativeJitterAddsNothing(t *testing.T) { + policy := &RetryPolicy{MaxAttempts: 2, BaseDelay: time.Second, Jitter: -time.Second} + + if got := policy.SleepFor(0); got != time.Second { + t.Errorf("got %v, want 1s rather than a panic", got) + } +} + +func TestNoRetryWithoutAPolicy(t *testing.T) { + cases := map[string]*RetryPolicy{ + "no policy": nil, + "one attempt": {MaxAttempts: 1, BaseDelay: time.Millisecond}, + } + + for name, policy := range cases { + t.Run(name, func(t *testing.T) { + var attempts atomic.Int32 + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(520) + }) + + client := newAPIClient(newHTTPClient(Chrome136Profile()), policy) + + if _, err := client.request(context.Background(), server.URL, requestOptions{}); !errors.Is(err, ErrCloudflare) { + t.Fatalf("got %v, want a Cloudflare error", err) + } + if attempts.Load() != 1 { + t.Errorf("got %d attempts, want 1", attempts.Load()) + } + }) + } +} + +func TestDeflateRefusesABodyThatIsNotFullyConsumed(t *testing.T) { + // Raw deflate carries neither a header nor a checksum, so a prefix that + // happens to inflate must not pass as the whole body. + body := append(rawDeflateBytes(t, []byte("legit body")), []byte("GARBAGEGARBAGE")...) + + if decoded, err := decompressDeflate(body, MaxResponseBytes); err == nil { + t.Errorf("got %q with no error, want the partial decode refused", decoded) + } + + // The zlib wrapper vouches for its own body, so padding stays tolerated. + padded := append(zlibBytes(t, []byte("legit body")), 0, 0, 0) + + if decoded, err := decompressDeflate(padded, MaxResponseBytes); err != nil { + t.Errorf("got %v, want the zlib body to survive its padding (decoded %q)", err, decoded) + } +} + +func TestTheLimitErrorNamesTheBudget(t *testing.T) { + // Across members the message used to report what was left of the budget. + body := append(gzipBytes(t, []byte("aaaa")), gzipBytes(t, []byte("bbbbbbbbbb"))...) + _, err := decompressGzip(body, 10) + + if err == nil { + t.Fatal("expected a decompression limit error") + } + if !strings.Contains(err.Error(), "10 byte") { + t.Errorf("got %q, want the 10 byte budget named", err) + } +} diff --git a/go/flightradarapi/snapshots_test.go b/go/flightradarapi/snapshots_test.go new file mode 100644 index 0000000..d897182 --- /dev/null +++ b/go/flightradarapi/snapshots_test.go @@ -0,0 +1,452 @@ +//go:build integration + +// Live FR24 tests. Run with: go test -tags integration ./... +// +// Kept behind a build tag so the offline suite can gate PRs without depending +// on FR24 being reachable or its payloads being stable. +package flightradarapi + +import ( + "context" + "errors" + "fmt" + "maps" + "regexp" + "slices" + "testing" + "time" +) + +const liveTimeout = 30 * time.Second + +// liveClient retries Cloudflare blocks, which are the usual reason a live run +// fails for reasons unrelated to the code under test. +func liveClient(t *testing.T) *Client { + t.Helper() + retry, err := NewRetryPolicy(3) + + if err != nil { + t.Fatal(err) + } + + return New(Options{Retry: retry, Timeout: liveTimeout}) +} + +// retryLive runs check until it passes, pausing in between. FR24 answers a +// hammered session with an empty feed, so a whole-suite run makes the +// count-sensitive assertions flaky on their own. This is the counterpart of the +// Python suite's repeat_test decorator. +func retryLive(t *testing.T, check func() error) { + t.Helper() + + const attempts = 3 + const pause = 5 * time.Second + + var err error + + for attempt := range attempts { + if err = check(); err == nil { + return + } + if errors.Is(err, ErrCloudflare) { + t.Skipf("blocked by Cloudflare: %v", err) + } + if attempt < attempts-1 { + t.Logf("attempt %d: %v — retrying in %v", attempt+1, err, pause) + time.Sleep(pause) + } + } + t.Fatal(err) +} + +// skipIfBlocked ends the test when Cloudflare, not the code, is the problem. +func skipIfBlocked(t *testing.T, err error) { + t.Helper() + + if errors.Is(err, ErrCloudflare) { + t.Skipf("blocked by Cloudflare: %v", err) + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func liveFlights(t *testing.T, client *Client) []*Flight { + t.Helper() + var flights []*Flight + + retryLive(t, func() error { + var err error + flights, err = client.GetFlights(context.Background(), FlightSearch{}) + + if err != nil { + return err + } + if len(flights) == 0 { + return errors.New("GetFlights returned no flights — the tests below would be meaningless") + } + return nil + }) + return flights +} + +func TestLiveGetFlightsShape(t *testing.T) { + client := liveClient(t) + + retryLive(t, func() error { + flights, err := client.GetFlights(context.Background(), FlightSearch{}) + + if err != nil { + return err + } + if len(flights) < 100 { + return fmt.Errorf("got %d flights, want at least 100 from the unfiltered feed", len(flights)) + } + + flight := flights[0] + + if flight.ID == "" { + return errors.New("a flight must carry an ID") + } + if flight.Latitude == nil || flight.Longitude == nil { + return errors.New("a flight must carry a position") + } + return nil + }) +} + +func TestLiveGetFlightsByAirlineShape(t *testing.T) { + client := liveClient(t) + airlines := []string{"SWA", "GLO", "AZU", "UAL", "THY"} + + retryLive(t, func() error { + answered := 0 + + for _, airline := range airlines { + flights, err := client.GetFlights(context.Background(), FlightSearch{Airline: airline}) + + if err != nil { + return err + } + for _, flight := range flights { + if flight.AirlineICAO != airline { + return fmt.Errorf("asked for %s, got %s", airline, flight.AirlineICAO) + } + } + if len(flights) > 0 { + answered++ + } + } + if answered < 3 { + return fmt.Errorf("only %d of %d airlines had flights in the air", answered, len(airlines)) + } + return nil + }) +} + +func TestLiveGetFlightsByBoundsShape(t *testing.T) { + client := liveClient(t) + zones := client.GetZones() + + for _, name := range []string{"northamerica", "southamerica"} { + zone := zones[name] + + retryLive(t, func() error { + flights, err := client.GetFlights(context.Background(), FlightSearch{ + Bounds: client.GetBounds(zone), + }) + + if err != nil { + return err + } + if len(flights) < 30 { + return fmt.Errorf("%s: got %d flights, want at least 30", name, len(flights)) + } + + for _, flight := range flights { + if flight.Latitude == nil || flight.Longitude == nil { + continue + } + if *flight.Latitude > zone.TLY || *flight.Latitude < zone.BRY { + return fmt.Errorf("%s: latitude %v outside the zone", name, *flight.Latitude) + } + if *flight.Longitude < zone.TLX || *flight.Longitude > zone.BRX { + return fmt.Errorf("%s: longitude %v outside the zone", name, *flight.Longitude) + } + } + return nil + }) + } +} + +func TestLiveGetFlightDetailsShape(t *testing.T) { + client := liveClient(t) + flights := liveFlights(t, client) + flight := flights[len(flights)/2] + + details, err := client.GetFlightDetails(context.Background(), flight) + skipIfBlocked(t, err) + + for _, key := range []string{"aircraft", "airline", "airport", "status", "time", "trail"} { + if _, ok := details[key]; !ok { + t.Errorf("missing key %q in the flight details", key) + } + } + + flight.SetFlightDetails(details) + + if flight.Details == nil { + t.Fatal("details were not set on the flight") + } + if flight.Details.Raw == nil { + t.Error("the raw payload must be kept") + } +} + +func TestLiveGetAirportShape(t *testing.T) { + client := liveClient(t) + + for _, code := range []string{"ATL", "LAX", "DXB", "DFW"} { + airport, err := client.GetAirport(context.Background(), code, false) + skipIfBlocked(t, err) + + if airport.IATA != code { + t.Errorf("%s: got IATA %q", code, airport.IATA) + } + if airport.Name == "" || airport.ICAO == "" { + t.Errorf("%s: got %+v", code, airport) + } + if airport.Latitude == nil || airport.Longitude == nil { + t.Errorf("%s: an airport must carry a position", code) + } + } +} + +func TestLiveGetAirportDetailsShape(t *testing.T) { + client := liveClient(t) + + for _, code := range []string{"ATL", "LAX", "DXB", "DFW"} { + details, err := client.GetAirportDetails(context.Background(), code, 1, 1) + skipIfBlocked(t, err) + + for _, key := range []string{"airport", "airlines", "aircraftImages"} { + if _, ok := details[key]; !ok { + t.Errorf("%s: missing key %q", code, key) + } + } + + airport := getMap(getMap(getMap(details, "airport"), "pluginData"), "details") + position := getMap(airport, "position") + airportCode := getMap(airport, "code") + + if getString(airportCode, "iata") == "" || getString(airportCode, "icao") == "" { + t.Errorf("%s: got code %v", code, airportCode) + } + if getNumber(position, "latitude") == nil || getNumber(position, "longitude") == nil { + t.Errorf("%s: got position %v", code, position) + } + if getString(getMap(airport, "timezone"), "name") == "" { + t.Errorf("%s: missing the timezone name", code) + } + } +} + +func TestLiveGetAirlinesShape(t *testing.T) { + airlines, err := liveClient(t).GetAirlines(context.Background()) + skipIfBlocked(t, err) + + // Thresholds mirror the Python suite: a page that parsed into three rows is + // a broken parser, not a quiet day. + if len(airlines) < 100 { + t.Fatalf("got %d airlines, want at least 100", len(airlines)) + } + if airlines[0].Name == "" { + t.Errorf("got %+v", airlines[0]) + } + + wanted := map[string]bool{"LAN": true, "GLO": true, "DAL": true, "AZU": true, "UAE": true} + + for _, airline := range airlines { + delete(wanted, airline.ICAO) + } + if len(wanted) > 0 { + t.Errorf("missing well-known airlines: %v", slices.Sorted(maps.Keys(wanted))) + } +} + +func TestLiveGetAirportsShape(t *testing.T) { + client := liveClient(t) + airports, err := client.GetAirports(context.Background(), + []Country{CountryBrazil, CountryUnitedStates}) + skipIfBlocked(t, err) + + if len(airports) < 1800 { + t.Fatalf("got %d airports for BR+US, want at least 1800", len(airports)) + } + for _, airport := range airports { + if airport.Country != "Brazil" && airport.Country != "United States" { + t.Fatalf("got country %q, want only the two asked for", airport.Country) + } + } +} + +func TestLiveGetAirportsWithoutCountriesShape(t *testing.T) { + client := liveClient(t) + airports, err := client.GetAirports(context.Background(), nil) + skipIfBlocked(t, err) + + if len(airports) < 1800 { + t.Fatalf("got %d airports, want at least 1800", len(airports)) + } + + countries := map[string]bool{} + + for _, airport := range airports { + countries[airport.Country] = true + } + if len(countries) <= 2 { + t.Fatalf("got %d countries, want the whole feed", len(countries)) + } + + // Discovered, not hard-coded: FR24 spells some names "Myanmar (Burma)", and + // the flag URL needs the slug of that spelling. + punctuated := []string{} + + for country := range countries { + if regexp.MustCompile(`[^a-zA-Z ]`).MatchString(country) { + punctuated = append(punctuated, country) + } + } + slices.Sort(punctuated) + tricky := airports[0].Country + + if len(punctuated) > 0 { + tricky = punctuated[0] + } + + flag, err := client.GetCountryFlag(context.Background(), tricky) + skipIfBlocked(t, err) + + if flag == nil { + t.Errorf("no flag for %q", tricky) + } + + // The Country constants and the feed's display names are two FR24 + // vocabularies; a rename silently empties that country's filter. The reverse + // is not asserted: a country FR24 adds is a gap in the constants, not a + // regression. + slugs := map[Country]bool{} + + for country := range countries { + slugs[Country(countryToSlug(country))] = true + } + + var absent []string + + for _, country := range AllCountries() { + if !slugs[country] { + absent = append(absent, string(country)) + } + } + if len(absent) > 0 { + slices.Sort(absent) + t.Errorf("Country constants absent from the feed: %v", absent) + } +} + +func TestLiveGetZonesShape(t *testing.T) { + zones := liveClient(t).GetZones() + + if len(zones) == 0 { + t.Fatal("no zones returned") + } + for name, zone := range zones { + if zone.TLY == 0 || zone.TLX == 0 || zone.BRY == 0 || zone.BRX == 0 { + t.Errorf("%s: got %+v", name, zone) + } + } +} + +func TestLiveGetAirlineLogoShape(t *testing.T) { + client := liveClient(t) + airlines := [][2]string{{"WN", "SWA"}, {"G3", "GLO"}, {"AD", "AZU"}, {"AA", "AAL"}, {"TK", "THY"}} + found := 0 + + for _, airline := range airlines { + logo, err := client.GetAirlineLogo(context.Background(), airline[0], airline[1]) + skipIfBlocked(t, err) + + if logo == nil { + continue + } + if logo.Extension == "" { + t.Errorf("%v: got no extension", airline) + } + + // A real image, not an error page FR24 served with a 200. + if len(logo.Data) > 512 { + found++ + } + } + + // Same 80% floor the Python suite uses: FR24 does drop the odd logo. + if wanted := len(airlines) * 4 / 5; found < wanted { + t.Errorf("got %d logos, want at least %d", found, wanted) + } +} + +func TestLiveGetCountryFlagShape(t *testing.T) { + client := liveClient(t) + countries := []string{"United States", "Brazil", "Egypt", "Japan", "South Korea", "Canada"} + found := 0 + + for _, country := range countries { + flag, err := client.GetCountryFlag(context.Background(), country) + skipIfBlocked(t, err) + + if flag == nil { + continue + } + if flag.Extension == "" { + t.Errorf("%s: got no extension", country) + } + if len(flag.Data) > 512 { + found++ + } + } + + if wanted := len(countries) * 4 / 5; found < wanted { + t.Errorf("got %d flags, want at least %d", found, wanted) + } +} + +func TestLivePlainJSONEndpointsShape(t *testing.T) { + client := liveClient(t) + ctx := context.Background() + + calls := map[string]func() (map[string]any, error){ + "most tracked": func() (map[string]any, error) { return client.GetMostTracked(ctx) }, + "airport disruptions": func() (map[string]any, error) { return client.GetAirportDisruptions(ctx) }, + "volcanic eruptions": func() (map[string]any, error) { return client.GetVolcanicEruptions(ctx) }, + } + + for name, call := range calls { + t.Run(name, func(t *testing.T) { + content, err := call() + skipIfBlocked(t, err) + + if content == nil { + t.Error("got no content") + } + }) + } +} + +func TestLiveSearchShape(t *testing.T) { + groups, err := liveClient(t).Search(context.Background(), "Guarulhos", 50) + skipIfBlocked(t, err) + + if len(groups) == 0 { + t.Error("got no groups") + } +} diff --git a/go/flightradarapi/testdata/airlines.html b/go/flightradarapi/testdata/airlines.html new file mode 100644 index 0000000..3c56b99 --- /dev/null +++ b/go/flightradarapi/testdata/airlines.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ LATAM Airlines + xxLA / LAN340 aircraft
+ Gol + xxG3 / GLO140 aircraft
+ Delta Air Lines + xxDL / DAL900 aircraft
+ Sky2 + xxSK10 aircraft
+ SkyTeam + xxSKT15 aircraft
placeholder
+ Should Skip +
+ diff --git a/go/flightradarapi/testdata/airports.json b/go/flightradarapi/testdata/airports.json new file mode 100644 index 0000000..924f9db --- /dev/null +++ b/go/flightradarapi/testdata/airports.json @@ -0,0 +1,77 @@ +{ + "version": "1786010717", + "rows": [ + { + "name": "Sao Paulo Guarulhos International Airport", + "iata": "GRU", + "icao": "SBGR", + "lat": -23.429991, + "lon": -46.4674, + "country": "Brazil", + "alt": 2436 + }, + { + "name": "Rio de Janeiro Galeao International Airport", + "iata": "GIG", + "icao": "SBGL", + "lat": -22.805696, + "lon": -43.25523, + "country": "Brazil", + "alt": 22 + }, + { + "name": "Sao Paulo Congonhas Airport", + "iata": "CGH", + "icao": "SBSP", + "lat": -23.627348, + "lon": -46.655987, + "country": "Brazil", + "alt": 2614 + }, + { + "name": "Los Angeles International Airport", + "iata": "LAX", + "icao": "KLAX", + "lat": 33.94252, + "lon": -118.406998, + "country": "United States", + "alt": 125 + }, + { + "name": "New York John F. Kennedy International Airport", + "iata": "JFK", + "icao": "KJFK", + "lat": 40.639751, + "lon": -73.7789, + "country": "United States", + "alt": 14 + }, + { + "name": "A Coruna Airport", + "iata": "LCG", + "icao": "LECO", + "lat": 43.302059, + "lon": -8.37725, + "country": "Spain", + "alt": 326 + }, + { + "name": "Ann Airport", + "iata": "VBA", + "icao": "VYAN", + "lat": 19.770643, + "lon": 94.026405, + "country": "Myanmar (Burma)", + "alt": "43" + }, + { + "name": "Broken Coords Airport", + "iata": "BAD", + "icao": "SBAD", + "lat": "n/a", + "lon": "", + "country": "Brazil", + "alt": 0 + } + ] +} diff --git a/go/flightradarapi/values.go b/go/flightradarapi/values.go new file mode 100644 index 0000000..26afa99 --- /dev/null +++ b/go/flightradarapi/values.go @@ -0,0 +1,83 @@ +package flightradarapi + +import "strconv" + +// DefaultText is the placeholder the Get* formatters return for a value the +// feed did not send. +const DefaultText = "N/A" + +// missing reports whether a feed value carries no information. FR24 sends the +// literal "N/A" as often as it sends null. +func missing(value any) bool { + if value == nil { + return true + } + text, ok := value.(string) + return ok && text == DefaultText +} + +func asMap(value any) map[string]any { + if nested, ok := value.(map[string]any); ok { + return nested + } + return map[string]any{} +} + +func asSlice(value any) []any { + if items, ok := value.([]any); ok { + return items + } + return []any{} +} + +func getMap(source map[string]any, key string) map[string]any { + return asMap(source[key]) +} + +func getSlice(source map[string]any, key string) []any { + return asSlice(source[key]) +} + +// getString returns a text field, or "" when the feed sent nothing usable. +func getString(source map[string]any, key string) string { + value := source[key] + + if missing(value) { + return "" + } + + switch typed := value.(type) { + case string: + return typed + case float64: + return formatNumber(typed) + case bool: + return strconv.FormatBool(typed) + default: + return "" + } +} + +func getNumber(source map[string]any, key string) *float64 { + if value := source[key]; !missing(value) { + return toNumber(value) + } + return nil +} + +func getBool(source map[string]any, key string) *bool { + value := source[key] + + if missing(value) { + return nil + } + if typed, ok := value.(bool); ok { + return &typed + } + return nil +} + +// formatNumber renders a number the way both other ports do: 2436, not 2436.0. +func formatNumber(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) +} diff --git a/go/flightradarapi/zones.go b/go/flightradarapi/zones.go new file mode 100644 index 0000000..21d801e --- /dev/null +++ b/go/flightradarapi/zones.go @@ -0,0 +1,222 @@ +// Code generated from python/FlightRadarAPI/zones.py. DO NOT EDIT. +// +// TestStaticZonesMatchThePythonSource fails when the Python source moves ahead +// of this file, which is the signal to regenerate it. + +package flightradarapi + +// Zone is a rectangular region of the globe, as FlightRadar24 defines it. +type Zone struct { + TLY float64 `json:"tl_y"` + TLX float64 `json:"tl_x"` + BRY float64 `json:"br_y"` + BRX float64 `json:"br_x"` + Subzones map[string]Zone `json:"subzones,omitempty"` +} + +// staticZones mirrors the payload of Core.zones_data_url, bundled so +// GetZones needs no request. +var staticZones = map[string]Zone{ + "europe": { + TLY: 72.57, + TLX: -16.96, + BRY: 33.57, + BRX: 53.05, + Subzones: map[string]Zone{ + "poland": { + TLY: 56.86, + TLX: 11.06, + BRY: 48.22, + BRX: 28.26, + }, + "germany": { + TLY: 57.92, + TLX: 1.81, + BRY: 45.81, + BRX: 16.83, + }, + "uk": { + TLY: 62.61, + TLX: -13.07, + BRY: 49.71, + BRX: 3.46, + Subzones: map[string]Zone{ + "london": { + TLY: 53.06, + TLX: -2.87, + BRY: 50.07, + BRX: 3.26, + }, + "ireland": { + TLY: 56.22, + TLX: -11.71, + BRY: 50.91, + BRX: -4.4, + }, + }, + }, + "spain": { + TLY: 44.36, + TLX: -11.06, + BRY: 35.76, + BRX: 4.04, + }, + "france": { + TLY: 51.07, + TLX: -5.18, + BRY: 42.17, + BRX: 8.9, + }, + "ceur": { + TLY: 51.39, + TLX: 11.25, + BRY: 39.72, + BRX: 32.55, + }, + "scandinavia": { + TLY: 72.12, + TLX: -0.73, + BRY: 53.82, + BRX: 40.67, + }, + "italy": { + TLY: 47.67, + TLX: 5.26, + BRY: 36.27, + BRX: 20.64, + }, + }, + }, + "northamerica": { + TLY: 75.0, + TLX: -180.0, + BRY: 3.0, + BRX: -52.0, + Subzones: map[string]Zone{ + "na_n": { + TLY: 72.82, + TLX: -177.97, + BRY: 41.92, + BRX: -52.48, + }, + "na_c": { + TLY: 54.66, + TLX: -134.68, + BRY: 22.16, + BRX: -56.91, + Subzones: map[string]Zone{ + "na_cny": { + TLY: 45.06, + TLX: -83.69, + BRY: 35.96, + BRX: -64.29, + }, + "na_cla": { + TLY: 37.91, + TLX: -126.12, + BRY: 30.21, + BRX: -110.02, + }, + "na_cat": { + TLY: 35.86, + TLX: -92.61, + BRY: 22.56, + BRX: -71.19, + }, + "na_cse": { + TLY: 49.12, + TLX: -126.15, + BRY: 42.97, + BRX: -111.92, + }, + "na_nw": { + TLY: 54.12, + TLX: -134.13, + BRY: 38.32, + BRX: -96.75, + }, + "na_ne": { + TLY: 53.72, + TLX: -98.76, + BRY: 38.22, + BRX: -57.36, + }, + "na_sw": { + TLY: 38.92, + TLX: -133.98, + BRY: 22.62, + BRX: -96.75, + }, + "na_se": { + TLY: 38.52, + TLX: -98.62, + BRY: 22.52, + BRX: -57.36, + }, + "na_cc": { + TLY: 45.92, + TLX: -116.88, + BRY: 27.62, + BRX: -75.91, + }, + }, + }, + "na_s": { + TLY: 41.92, + TLX: -177.83, + BRY: 3.82, + BRX: -52.48, + }, + }, + }, + "southamerica": { + TLY: 16.0, + TLX: -96.0, + BRY: -57.0, + BRX: -31.0, + }, + "oceania": { + TLY: 19.62, + TLX: 88.4, + BRY: -55.08, + BRX: 180.0, + }, + "asia": { + TLY: 79.98, + TLX: 40.91, + BRY: 12.48, + BRX: 179.77, + Subzones: map[string]Zone{ + "japan": { + TLY: 60.38, + TLX: 113.5, + BRY: 22.58, + BRX: 176.47, + }, + }, + }, + "africa": { + TLY: 39.0, + TLX: -29.0, + BRY: -39.0, + BRX: 55.0, + }, + "atlantic": { + TLY: 52.62, + TLX: -50.9, + BRY: 15.62, + BRX: -4.75, + }, + "maldives": { + TLY: 10.72, + TLX: 63.1, + BRY: -6.08, + BRX: 86.53, + }, + "northatlantic": { + TLY: 82.62, + TLX: -84.53, + BRY: 59.02, + BRX: 4.45, + }, +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..34838a6 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,9 @@ +module github.com/JeanExtreme002/FlightRadarAPI/go + +go 1.25.0 + +require ( + github.com/andybalholm/brotli v1.2.2 + golang.org/x/net v0.58.0 + golang.org/x/text v0.41.0 +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..6ecbbf0 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,8 @@ +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= From 6f8f124ccdf2e2dace96ac212197886982b00072 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 20:13:29 -0300 Subject: [PATCH 02/13] ci: run, release and label the Go port The offline suite gates pull requests and the live FR24 suite runs with retries, mirroring the Python and Node.js workflows. gofmt, go vet and staticcheck stand in for flake8/mypy and eslint/tsd, and a consumer module is built against the package so a broken public surface fails the build. Releases need no registry upload: for Go the tag is the release. Since the module lives in a subdirectory, the toolchain only sees tags carrying that prefix, so publish.yml now pushes go/vX.Y.Z alongside the release tag, and the version check covers all three ports. The labeler tells the Go tests apart from the package itself, which matters because Go keeps them in the same directory where python/ and nodejs/ keep a sibling tests/ folder. --- .github/dependabot.yml | 9 ++++ .github/workflows/go-package.yml | 87 ++++++++++++++++++++++++++++++++ .github/workflows/labeler.yml | 34 ++++++++++--- .github/workflows/publish.yml | 38 ++++++++++++-- 4 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/go-package.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 510a7e3..872245f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,6 +23,15 @@ updates: - "deps" - "security" + - package-ecosystem: "gomod" + directory: "/go" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "deps" + - "security" + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/go-package.yml b/.github/workflows/go-package.yml new file mode 100644 index 0000000..a26e63f --- /dev/null +++ b/.github/workflows/go-package.yml @@ -0,0 +1,87 @@ +name: Go Package + +on: + push: + branches: + - main + paths: + - 'go/**' + - '.github/workflows/go-package.yml' + pull_request: + paths: + - 'go/**' + - '.github/workflows/go-package.yml' + schedule: + - cron: '0 0 */7 * *' + workflow_dispatch: + +defaults: + run: + working-directory: ./go + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ['1.25.x', '1.26.x'] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: ${{ matrix.go-version }} + cache-dependency-path: go/go.sum + - name: Download dependencies + run: | + go mod download + go mod verify + - name: Check go.mod is tidy + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + - name: Lint + run: make lint + - name: Static analysis + if: matrix.go-version == '1.26.x' + run: make lint-strict + - name: Offline tests (PR gate) + # Coverage threshold lives in the Makefile ($(COVERAGE_MIN)); raise it + # there and it applies here too. + run: make test-coverage + - name: Race detector + run: make test-race + - name: Integration tests (live FR24) + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 + with: + timeout_minutes: 10 + max_attempts: 3 + command: cd go && make test-integration + continue-on-error: ${{ github.event_name == 'push' }} + - name: Vulnerability scan (shipped dependencies) + if: matrix.go-version == '1.26.x' + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + - name: Verify the package builds as a dependency + run: | + go build ./... + mkdir -p /tmp/consumer && cd /tmp/consumer + go mod init consumer + go mod edit -require=github.com/JeanExtreme002/FlightRadarAPI/go@v0.0.0 + go mod edit -replace=github.com/JeanExtreme002/FlightRadarAPI/go=$GITHUB_WORKSPACE/go + cat > main.go <<'EOF' + package main + + import ( + "fmt" + + "github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi" + ) + + func main() { + client := flightradarapi.New() + fmt.Println("Install OK:", len(client.GetZones()), "zones") + } + EOF + go mod tidy + go run . diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 14719ee..eb0a7a4 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -34,26 +34,46 @@ jobs: const paths = files.map((f) => f.filename); core.info(`Changed files (${paths.length}):\n ` + paths.join("\n ")); + const goPackage = "go/flightradarapi/"; + + // Go keeps its tests inside the package, where python/ and nodejs/ + // keep them in a sibling tests/ directory. Told apart here so a + // test-only PR gets "tests" and not "api". + const isGoTest = (f) => + f.startsWith(goPackage) && + (/_test\.go$/.test(f) || f.startsWith(goPackage + "testdata/")); + const isCore = (f) => f === "python/FlightRadarAPI/core.py" || f === "python/FlightRadarAPI/request.py" || f === "nodejs/FlightRadarAPI/core.js" || - f === "nodejs/FlightRadarAPI/request.js"; + f === "nodejs/FlightRadarAPI/request.js" || + f === "go/flightradarapi/core.go" || + f === "go/flightradarapi/request.go" || + // The Go port owns the cookie jar its siblings get from their + // HTTP client, so it belongs to the same layer as request.go. + f === "go/flightradarapi/cookies.go"; const isEntities = (f) => - /^(python|nodejs)\/FlightRadarAPI\/entities\//.test(f); + /^(python|nodejs)\/FlightRadarAPI\/entities\//.test(f) || + // One package per directory in Go: the entities are files. + /^go\/flightradarapi\/(entity|airport|flight)\.go$/.test(f); const isInPackage = (f) => - /^(python|nodejs)\/FlightRadarAPI\//.test(f); + /^(python|nodejs)\/FlightRadarAPI\//.test(f) || + (f.startsWith(goPackage) && !isGoTest(f)); - const isTests = (f) => /^(python|nodejs)\/tests\//.test(f); + const isTests = (f) => + /^(python|nodejs)\/tests\//.test(f) || isGoTest(f); const isCi = (f) => f.startsWith(".github/"); const isDeps = (f) => f === "python/pyproject.toml" || f === "nodejs/package.json" || - f === "nodejs/package-lock.json"; + f === "nodejs/package-lock.json" || + f === "go/go.mod" || + f === "go/go.sum"; const isBuild = (f) => /(^|\/)Makefile$/.test(f) || @@ -69,6 +89,7 @@ jobs: const isPython = (f) => f.startsWith("python/"); const isNode = (f) => f.startsWith("nodejs/"); + const isGo = (f) => f.startsWith("go/"); const desired = new Set(); @@ -83,6 +104,7 @@ jobs: if (isDocs(f)) desired.add("docs"); if (isPython(f)) desired.add("pkg:python"); if (isNode(f)) desired.add("pkg:node"); + if (isGo(f)) desired.add("pkg:go"); } // Dependabot PRs (we only enable Dependabot for security advisories). @@ -119,7 +141,7 @@ jobs: "api:core", "api:entities", "api", "tests", "ci", "build", "feature", "docs", "revert", "performance", "bug", - "deps", "security", "pkg:python", "pkg:node", + "deps", "security", "pkg:python", "pkg:node", "pkg:go", ]); const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ba1732c..677932d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,14 +32,19 @@ jobs: run: | py_version=$(grep -oP '__version__\s*=\s*"\K[^"]+' python/FlightRadarAPI/__init__.py) npm_version=$(node -p "require('./nodejs/package.json').version") + go_version=$(grep -oP 'const Version = "\K[^"]+' go/flightradarapi/doc.go) echo "python=$py_version" >> "$GITHUB_OUTPUT" echo "npm=$npm_version" >> "$GITHUB_OUTPUT" - echo "Python: $py_version | npm: $npm_version" + echo "go=$go_version" >> "$GITHUB_OUTPUT" + echo "Python: $py_version | npm: $npm_version | Go: $go_version" - name: Check versions are in sync + # The Go module is published by the release tag itself, so it has no + # upload job below — only this check keeps its version honest. run: | - if [ "${{ steps.versions.outputs.python }}" != "${{ steps.versions.outputs.npm }}" ]; then - echo "::error::Version mismatch: python=${{ steps.versions.outputs.python }} npm=${{ steps.versions.outputs.npm }}" + if [ "${{ steps.versions.outputs.python }}" != "${{ steps.versions.outputs.npm }}" ] \ + || [ "${{ steps.versions.outputs.python }}" != "${{ steps.versions.outputs.go }}" ]; then + echo "::error::Version mismatch: python=${{ steps.versions.outputs.python }} npm=${{ steps.versions.outputs.npm }} go=${{ steps.versions.outputs.go }}" exit 1 fi @@ -53,6 +58,33 @@ jobs: exit 1 fi + tag-go-module: + needs: verify-versions + if: github.event_name == 'release' + runs-on: ubuntu-latest + permissions: + contents: write # Required to push the module tag + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + # A module in a subdirectory is released by a tag carrying that prefix, so + # `go get .../go@latest` sees nothing without this. + - name: Tag the Go module + env: + TAG: ${{ github.event.release.tag_name }} + run: | + version="${TAG#v}" + module_tag="go/v${version}" + if git rev-parse -q --verify "refs/tags/${module_tag}" >/dev/null; then + echo "${module_tag} already exists" + exit 0 + fi + git tag "${module_tag}" "${TAG}" + git push origin "${module_tag}" + echo "Pushed ${module_tag}" + publish-pypi: needs: verify-versions if: github.event_name == 'release' || inputs.target == 'both' || inputs.target == 'pypi' From dd8d052a1b4713f79030aed98fed811d2ee5f872 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 20:13:29 -0300 Subject: [PATCH 03/13] docs: list Go across the project docs The per-package READMEs now describe only their own port, and the project-wide pages list all three. The issue templates asked for a "Python Version", which was already wrong for Node.js. --- .github/ISSUE_TEMPLATE/bug_report.md | 4 ++- .github/ISSUE_TEMPLATE/questioning.md | 4 ++- CONTRIBUTING.md | 47 +++++++++++++++++++++------ README.md | 10 ++++-- docs/index.md | 14 ++++++-- mkdocs.yml | 1 + nodejs/README.md | 4 +-- python/README.md | 4 +-- 8 files changed, 67 insertions(+), 21 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 557652b..c582c3f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,7 +26,9 @@ If applicable, add screenshots to help explain your problem. **System (please complete the following information):** - OS: [e.g. Windows] - - Python Version [e.g. 1.10] + - SDK: [Python, Node.js or Go] + - Language version: [e.g. Python 3.12, Node.js 22, Go 1.26] + - FlightRadarAPI version: [e.g. 1.6.0] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/questioning.md b/.github/ISSUE_TEMPLATE/questioning.md index 5691d78..83a8e16 100644 --- a/.github/ISSUE_TEMPLATE/questioning.md +++ b/.github/ISSUE_TEMPLATE/questioning.md @@ -29,7 +29,9 @@ If applicable, add screenshots to help explain your problem. If applicable, please complete the following information: 1. OS: [e.g. Windows] -2. Python Version [e.g. 1.10] +2. SDK: [Python, Node.js or Go] +3. Language version: [e.g. Python 3.12, Node.js 22, Go 1.26] +4. FlightRadarAPI version: [e.g. 1.6.0] **Additional context** Add any other context about the problem here. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63c6d46..6df9c97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ # Contributing to FlightRadarAPI -Thanks for your interest. This repo ships two SDKs in parallel — Python and -Node.js — that must stay behavior-aligned, so most non-trivial changes touch -both sides. +Thanks for your interest. This repo ships three SDKs in parallel — Python, +Node.js and Go — that must stay behavior-aligned, so most non-trivial changes +touch every side. ## Development setup @@ -25,25 +25,37 @@ make lint # eslint make test-types # tsd ``` -## Keeping Python and Node aligned +### Go +```bash +cd go +make deps +make test # offline suite (the PR gate) +make test-integration # live FR24 suite +make lint # gofmt + go vet +make lint-strict # adds staticcheck +``` + +## Keeping the SDKs aligned -When you change behavior, change it in both SDKs in the same PR unless there +When you change behavior, change it in every SDK in the same PR unless there is a documented reason not to. Common targets that must stay in sync: - Error taxonomy (`AirportNotFoundError`, `LoginError`, `CloudflareError`, `FlightRadarError`). - `RetryPolicy` semantics (which exceptions are transient, backoff math). - Cloudflare detection rules. -- The public surface — `FlightRadar24API` methods, the `Countries` enum, - `FlightTrackerConfig` fields, and the `Entity` / `Airport` / `Flight` - attributes consumers depend on. +- The public surface — `FlightRadar24API` methods (`Client` in Go), the + `Countries` enum (`Country` constants in Go), `FlightTrackerConfig` fields, + and the `Entity` / `Airport` / `Flight` attributes consumers depend on. + `go/README.md` documents where the Go surface deliberately differs. ## Style - Python: flake8 + mypy. - Node: eslint + tsd. +- Go: gofmt + go vet + staticcheck. - Comments must explain **why**, not **what**. The codebase has a few exemplars in - `request.py`/`request.js` — read those before adding new comments. + `request.py`/`request.js`/`request.go` — read those before adding new comments. ## Commits and PRs @@ -53,10 +65,25 @@ is a documented reason not to. Common targets that must stay in sync: ## Releases -Before publishing a new release, the version **must be bumped**. The version lives in two places: +Before publishing a new release, the version **must be bumped**. The version lives in three places: - `python/FlightRadarAPI/__init__.py` (`__version__`) - `nodejs/package.json` (`version`) +- `go/flightradarapi/doc.go` (`Version`) + +The Go module needs no registry upload — the tag *is* the release. Because it +lives in a subdirectory, the Go toolchain only sees tags carrying that prefix, +so the `tag-go-module` job of `publish.yml` pushes `go/v1.6.0` alongside the +release tag. Nothing to do by hand; if that job is skipped, `go get +.../go@latest` finds no release and callers fall back to a pseudo-version. + +## Generated files + +`go/flightradarapi/countries.go` and `zones.go` are generated from +`python/FlightRadarAPI/core.py` and `zones.py`. Change the Python source first, +then regenerate them — `go/flightradarapi/ports_test.go` fails with the +exact entries that drifted, and also checks `FlightTrackerConfig` against the +Python dataclass. ## Reporting bugs and asking questions diff --git a/README.md b/README.md index 887612c..3f934df 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # FlightRadarAPI -Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python 3 and Node.js. +Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python 3, Node.js and Go. This SDK should only be used for your own educational purposes. If you are interested in accessing Flightradar24 data commercially, please contact business@fr24.com. See more information at [Flightradar24's terms and conditions](https://www.flightradar24.com/terms-and-conditions). **Official FR24 API**: https://fr24api.flightradar24.com/ [![Python Package](https://github.com/JeanExtreme002/FlightRadarAPI/workflows/Python%20Package/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) +[![Go Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/go-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) [![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) @@ -24,8 +25,13 @@ pip install FlightRadarAPI npm install flightradarapi ``` +**For Go:** +``` +go get github.com/JeanExtreme002/FlightRadarAPI/go@latest +``` + ## Documentation -Explore the docs of FlightRadarAPI package, for Python or NodeJS, through [FlightRadarAPI Documentation](https://JeanExtreme002.github.io/FlightRadarAPI/) page. +Explore the docs of FlightRadarAPI package, for Python, NodeJS or Go, through [FlightRadarAPI Documentation](https://JeanExtreme002.github.io/FlightRadarAPI/) page. ## Project resources **Contributing**: [`CONTRIBUTING.md`](CONTRIBUTING.md)
diff --git a/docs/index.md b/docs/index.md index 6b04dfa..53540a5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # FlightRadarAPI Documentation -Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python 3 and Node.js. +Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python 3, Node.js and Go. This SDK should only be used for your own educational purposes. If you are interested in accessing Flightradar24 data commercially, please contact [business@fr24.com](business@fr24.com). @@ -26,11 +26,11 @@ See more information at [Flightradar24's terms and conditions](https://www.fligh The code is open source and available for inspection on GitHub. -- :material-sticker-check-outline:{ .lg .middle } __Python and Node.js__ +- :material-sticker-check-outline:{ .lg .middle } __Python, Node.js and Go__ --- - Packages are avaiable for use on both Python and Node.js + Packages are available for use on Python, Node.js and Go @@ -57,3 +57,11 @@ See more information at [Flightradar24's terms and conditions](https://www.fligh ```bash npm install flightradarapi ``` + +=== "Go" + + To install FlightRadarAPI for Go, run the following command in your terminal: + + ```bash + go get github.com/JeanExtreme002/FlightRadarAPI/go@latest + ``` diff --git a/mkdocs.yml b/mkdocs.yml index f30ade3..bf52fc4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,7 @@ nav: - Home: index.md - Python: python.md - Node.js: nodejs.md + - Go: go.md - Projects Using FlightRadarAPI: projects.md theme: diff --git a/nodejs/README.md b/nodejs/README.md index 3abf5b9..0c9eab7 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -1,5 +1,5 @@ # FlightRadarAPI -Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python 3 and Node.js. +Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Node.js. This SDK should only be used for your own educational purposes. If you are interested in accessing Flightradar24 data commercially, please contact business@fr24.com. See more information at [Flightradar24's terms and conditions](https://www.flightradar24.com/terms-and-conditions). @@ -64,4 +64,4 @@ Countries.FRANCE // "france" ``` ## Documentation -Explore the documentation of FlightRadarAPI package, for Python or NodeJS, through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/). +Explore the documentation of FlightRadarAPI package through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/). diff --git a/python/README.md b/python/README.md index 5c87ffa..956844e 100644 --- a/python/README.md +++ b/python/README.md @@ -1,5 +1,5 @@ # FlightRadarAPI -Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python 3 and Node.js. +Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python. This SDK should only be used for your own educational purposes. If you are interested in accessing Flightradar24 data commercially, please contact business@fr24.com. See more information at [Flightradar24's terms and conditions](https://www.flightradar24.com/terms-and-conditions). @@ -44,4 +44,4 @@ zones = fr_api.get_zones() ``` ## Documentation -Explore the documentation of FlightRadarAPI package, for Python or NodeJS, through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/). +Explore the documentation of FlightRadarAPI package through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/). From 1dcc332191e43577420f9182e41a62f8ed58da33 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 20:28:11 -0300 Subject: [PATCH 04/13] docs: trim the Go readme to the shape of its siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package readme now carries what the Python and Node.js ones do — install, basic usage, documentation link — and nothing else. The deeper material it held (entity constructors, client options, error handling, TLS impersonation, the differences table) moves to docs/go.md, where the equivalent Python and Node.js pages already live. --- CONTRIBUTING.md | 2 +- docs/go.md | 25 +++++ go/README.md | 237 +++--------------------------------------------- 3 files changed, 39 insertions(+), 225 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6df9c97..b70dbe3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ is a documented reason not to. Common targets that must stay in sync: - The public surface — `FlightRadar24API` methods (`Client` in Go), the `Countries` enum (`Country` constants in Go), `FlightTrackerConfig` fields, and the `Entity` / `Airport` / `Flight` attributes consumers depend on. - `go/README.md` documents where the Go surface deliberately differs. + `docs/go.md` documents where the Go surface deliberately differs. ## Style diff --git a/docs/go.md b/docs/go.md index 4419d0c..95b3a2f 100644 --- a/docs/go.md +++ b/docs/go.md @@ -127,6 +127,17 @@ matched, err := flight.CheckInfo(map[string]any{ }) ``` +### Building Entities From a Payload You Already Have + +The constructors the Python and Node.js ports expose have counterparts here: + +```go +airport := flightradarapi.NewAirportFromBasicInfo(row) // one airports-feed row +airport = flightradarapi.NewAirportFromInfo(info) // the "details" block +airport = flightradarapi.NewAirportFromDetails(payload) // a GetAirportDetails payload +flight := flightradarapi.NewFlight("2e0f1a2", feedRow) // one live-feed row +``` + ### Fetching Airport by ICAO or IATA ```go @@ -204,6 +215,20 @@ retry, err := flightradarapi.NewRetryPolicy(3) client := flightradarapi.New(flightradarapi.Options{Retry: retry}) ``` +### Configuring the Client + +Every field of `Options` defaults from its zero value, so set only what you need: + +```go +retry, err := flightradarapi.NewRetryPolicy(3) // 1s base, 30s cap, 500ms jitter + +client := flightradarapi.New(flightradarapi.Options{ + Timeout: 10 * time.Second, // default: 30s + MaxWorkers: 4, // default: 8 + Retry: retry, // default: no retry +}) +``` + ### TLS Impersonation FlightRadar24 fingerprints TLS handshakes through Cloudflare. The default client diff --git a/go/README.md b/go/README.md index d384bad..704c631 100644 --- a/go/README.md +++ b/go/README.md @@ -10,60 +10,29 @@ This SDK should only be used for your own educational purposes. If you are inter [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) [![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) -## Installing FlightRadarAPI - -```bash -go get github.com/JeanExtreme002/FlightRadarAPI/go@latest +## Installing FlightRadarAPI: ``` - -The module lives in a subdirectory of the repository, so its releases are the -tags prefixed with it (`go/v1.6.0`). To track the branch instead: - -```bash -go get github.com/JeanExtreme002/FlightRadarAPI/go@main +$ go get github.com/JeanExtreme002/FlightRadarAPI/go ``` -## Basic Usage - -Create a client and call its methods. Construction cannot fail, so it needs no -error handling; every method that talks to FR24 takes a `context.Context` and -returns an error. - +## Basic Usage: +Import the package and create a client. Every method that talks to FlightRadar24 takes a `context.Context` and returns an `error` alongside its result. ```go -package main +import "github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi" -import ( - "context" - "fmt" - "log" - - "github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi" -) - -func main() { - client := flightradarapi.New() - - flights, err := client.GetFlights(context.Background(), flightradarapi.FlightSearch{}) - if err != nil { - log.Fatal(err) - } - - for _, flight := range flights[:min(5, len(flights))] { - fmt.Println(flight, flight.GetFlightLevel()) - } -} +client := flightradarapi.New() ``` **Getting flights list:** ```go -flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{}) // Returns []*Flight +flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{}) // Returns a list of Flight objects ``` **Getting airports list:** ```go // Get airports from specific countries airports, err := client.GetAirports(ctx, []flightradarapi.Country{ - flightradarapi.CountryBrazil, flightradarapi.CountryUnitedStates, + flightradarapi.CountryBrazil, flightradarapi.CountryUnitedStates, }) // Pass nil to get every airport @@ -72,7 +41,7 @@ allAirports, err := client.GetAirports(ctx, nil) **Getting airlines list:** ```go -airlines, err := client.GetAirlines(ctx) // Returns []Airline with IATA/ICAO codes +airlines, err := client.GetAirlines(ctx) // Returns detailed airline information with IATA/ICAO codes ``` **Getting zones list:** @@ -82,196 +51,16 @@ zones := client.GetZones() **Using the Country constants:** ```go +// Available countries, the counterpart of the Countries enum in the other packages flightradarapi.CountryUnitedStates // "united-states" flightradarapi.CountryBrazil // "brazil" flightradarapi.CountryGermany // "germany" flightradarapi.CountryFrance // "france" // ... and many more -// Any spelling works — values are slugified before matching. -flightradarapi.Country("Myanmar (Burma)") // same filter as CountryMyanmarBurma - -// AllCountries() enumerates them, like list(Countries) does in Python. -for _, country := range flightradarapi.AllCountries() { … } +// AllCountries() enumerates them all +for _, country := range flightradarapi.AllCountries() { } ``` -## Fetching Detailed Information - -```go -// Flight details -details, err := client.GetFlightDetails(ctx, flight) -flight.SetFlightDetails(details) -fmt.Println(flight.Details.AirlineName, flight.Details.OriginAirportName) - -// Every flight with its details, MaxWorkers requests at a time -flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{Details: true}) - -// Airport details -airport, err := client.GetAirport(ctx, "ATL", true) -fmt.Println(airport.Name, airport.TimezoneName, len(airport.Runways)) -``` - -## Advanced Usage - -**Fetching flights above a specific position:** -```go -// Your point is 52°34'04.7"N 13°16'57.5"E from Google Maps and radius 2 km -bounds := client.GetBoundsByPoint(52.567774, 13.282827, 2000) - -flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{Bounds: bounds}) -``` - -**Filtering flights and airports:** -```go -airportBounds := client.GetBounds(client.GetZones()["northamerica"]) -// Or set a custom region: bounds := "73,-12,-156,38" - -flights, err := client.GetFlights(ctx, flightradarapi.FlightSearch{ - Airline: "SWA", Bounds: airportBounds, AircraftType: "B738", -}) - -// Filter a flight on its values, with optional min_/max_ prefixes -matched, err := flight.CheckInfo(map[string]any{ - "min_altitude": 6700, "max_altitude": 13000, "airline_icao": "THY", -}) -``` - -**Calculating the distance between flights and airports:** -```go -distance, err := airport.GetDistanceFrom(flight) // In kilometers -``` - -**Downloading flight data** (requires a premium subscription): -```go -if err := client.Login(ctx, "email", "password"); err != nil { - log.Fatal(err) -} - -data, err := client.GetHistoryData(ctx, flight, "CSV", timestamp) -``` - -**Setting the Real Time Flight Tracker parameters:** -```go -// Replace the whole config, apply single values, or both -err := client.SetFlightTrackerConfig(nil, map[string]string{"limit": "10", "maxage": "600"}) - -config := client.GetFlightTrackerConfig() // A copy, safe to keep -``` - -**Configuring the client:** -```go -retry, err := flightradarapi.NewRetryPolicy(3) // 1s base, 30s cap, 500ms jitter - -// Every field's zero value means the default, so set only what you need. -client := flightradarapi.New(flightradarapi.Options{ - Timeout: 10 * time.Second, // default: 30s - MaxWorkers: 4, // default: 8 - Retry: retry, // default: no retry -}) -``` - -## Building Entities Yourself - -The constructors the Python and Node.js ports expose have counterparts here, for -payloads you already hold: - -```go -airport := flightradarapi.NewAirportFromBasicInfo(row) // one airports-feed row -airport = flightradarapi.NewAirportFromInfo(info) // the "details" block -airport = flightradarapi.NewAirportFromDetails(payload) // a GetAirportDetails payload -flight := flightradarapi.NewFlight("2e0f1a2", feedRow) // one live-feed row -``` - -## Error Handling - -Every error wraps a sentinel, so `errors.Is` and `errors.As` both work: - -```go -airport, err := client.GetAirport(ctx, "XXX", false) - -switch { -case errors.Is(err, flightradarapi.ErrAirportNotFound): - // no such airport -case errors.Is(err, flightradarapi.ErrCloudflare): - // blocked by Cloudflare — back off, or plug in TLS impersonation -case errors.Is(err, flightradarapi.ErrLogin): - // the endpoint needs an account -} - -var cloudflareErr *flightradarapi.CloudflareError - -if errors.As(err, &cloudflareErr) { - fmt.Println(string(cloudflareErr.Body)) // the challenge page -} -``` - -## TLS Impersonation - -FR24 fronts its site with Cloudflare, which fingerprints TLS handshakes. The -default client narrows Go's offered cipher suites and curve order to Chrome's -([`Chrome136Profile`](flightradarapi/request.go)), which is enough today. -Go fixes its own cipher ordering, so this is an approximation rather than a -byte-exact JA3. For full impersonation, plug in a client built on -[utls](https://github.com/refraction-networking/utls) or -[tls-client](https://github.com/bogdanfinn/tls-client): - -```go -client := flightradarapi.New(flightradarapi.Options{ - HTTPClient: &http.Client{Transport: myImpersonatingTransport}, -}) -``` - -Set `DisableCompression` on your transport so this package keeps owning content -decoding, and with it the response size budget. Leave `CheckRedirect` unset too, -or the cookies FR24 hands out on a redirect hop are not banked. - -## Differences from the Python and Node.js ports - -The features are the same; the names differ only where Go's conventions do. The -package name is part of every identifier, so the client is `Client` rather than -`FlightRadar24API` — the way it is `http.Client` and not `http.HTTPClient`. - -| Python / Node.js | Go | -| --- | --- | -| `FlightRadar24API` | `Client`, built with `New` | -| `new FlightRadar24API({timeout, maxWorkers})` | `New(Options{Timeout: ..., MaxWorkers: ...})` | -| `FlightRadarError` (base class) | `ErrFlightRadar`, wrapped by every error here | -| `Countries.BRAZIL` | `CountryBrazil`, with `AllCountries()` to enumerate | -| `FlightRadar24API(user, password)` logs in (Python only) | `client.Login(ctx, user, password)` | -| `get_flights(airline, bounds, registration, aircraft_type, details)` | `GetFlights(ctx, FlightSearch{...})` | -| `check_info(min_altitude=6700)` | `CheckInfo(map[string]any{"min_altitude": 6700})` | -| `Airport.from_details(payload)` | `NewAirportFromDetails(payload)` | -| `(bytes, extension)` tuple | `*Image` | -| `airline["n_aircrafts"]` | `Airline.NumAircrafts` | -| `flight.destination_airport_name` | `flight.Details.DestinationAirportName` | -| default arguments (`flight_limit=100`, `limit=50`) | zero means the same default | -| `get_airports(None)` for every airport | `GetAirports(ctx, nil)` | -| exceptions | `error` values wrapping `ErrFlightRadar` | -| missing value is the string `"N/A"` | zero value: `""` or a nil pointer | -| `parsers` module (internal) | unexported functions | -| blocking calls | `context.Context` on every request | - -Every method of `FlightRadar24API` has a counterpart here, with the same -parameters — `ports_test.go` reads the Python source and fails if that stops -being true. - ## Documentation - -Explore the documentation of the FlightRadarAPI package through -[this site](https://JeanExtreme002.github.io/FlightRadarAPI/), or read the -[Go reference](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi). - -## Development - -```bash -cd go -make deps -make test # offline suite (the PR gate) -make test-integration # live FR24 suite -make lint # gofmt + go vet -make lint-strict # adds staticcheck -make test-coverage -``` - -`countries.go` and `zones.go` are generated from the Python port; -`ports_test.go` fails when the two drift apart. +Explore the documentation of FlightRadarAPI package through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/), or read the [Go reference](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi). From e5fbb04537418710f7c693f94f5fc4a704d793c0 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 20:36:24 -0300 Subject: [PATCH 05/13] docs: carry the Go badges in every readme Each readme leads with its own workflow badge and then repeats the project-wide block, so the Go one was missing Pypi, Npm, Downloads and Frequency, and the other three were missing the Go reference and version. The project-wide pages listed only some of the workflow badges: the root readme had no Node.js one and the documentation home had neither Node.js nor Go. --- README.md | 3 +++ docs/index.md | 4 ++++ go/README.md | 7 ++++++- nodejs/README.md | 2 ++ python/README.md | 2 ++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f934df..e9dc830 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,14 @@ This SDK should only be used for your own educational purposes. If you are inter **Official FR24 API**: https://fr24api.flightradar24.com/ [![Python Package](https://github.com/JeanExtreme002/FlightRadarAPI/workflows/Python%20Package/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) +[![Node.js Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/node-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) [![Go Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/go-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) [![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) [![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](https://pypi.org/project/FlightRadarAPI/) diff --git a/docs/index.md b/docs/index.md index 53540a5..f5cfc52 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,10 +7,14 @@ This SDK should only be used for your own educational purposes. If you are inter See more information at [Flightradar24's terms and conditions](https://www.flightradar24.com/terms-and-conditions). [![Python Package](https://github.com/JeanExtreme002/FlightRadarAPI/workflows/Python%20Package/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) +[![Node.js Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/node-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) +[![Go Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/go-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) [![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) [![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](https://pypi.org/project/FlightRadarAPI/) diff --git a/go/README.md b/go/README.md index 704c631..4b1309f 100644 --- a/go/README.md +++ b/go/README.md @@ -6,9 +6,14 @@ This SDK should only be used for your own educational purposes. If you are inter **Official FR24 API**: https://fr24api.flightradar24.com/ [![Go Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/go-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) -[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) +[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) +[![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) [![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) +[![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) +[![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](https://pypi.org/project/FlightRadarAPI/) ## Installing FlightRadarAPI: ``` diff --git a/nodejs/README.md b/nodejs/README.md index 0c9eab7..6da5aca 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -10,6 +10,8 @@ This SDK should only be used for your own educational purposes. If you are inter [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) [![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](https://pypi.org/project/FlightRadarAPI/) diff --git a/python/README.md b/python/README.md index 956844e..aa6e962 100644 --- a/python/README.md +++ b/python/README.md @@ -10,6 +10,8 @@ This SDK should only be used for your own educational purposes. If you are inter [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) [![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](https://pypi.org/project/FlightRadarAPI/) From d8a074743e80cca741bc0f590543b8e10c670712 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 20:47:46 -0300 Subject: [PATCH 06/13] docs: add a Node version badge and group the license with the workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each registry badge now sits next to the version it needs — Pypi with Python, Npm with Node, the Go reference with Go — and the license moves up beside the workflow badges. The Node floor is the engines.node of nodejs/package.json. --- README.md | 3 ++- docs/index.md | 3 ++- go/README.md | 3 ++- nodejs/README.md | 3 ++- python/README.md | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e9dc830..2bab231 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,11 @@ This SDK should only be used for your own educational purposes. If you are inter [![Python Package](https://github.com/JeanExtreme002/FlightRadarAPI/workflows/Python%20Package/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) [![Node.js Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/node-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) [![Go Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/go-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) -[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi) [![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) [![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) diff --git a/docs/index.md b/docs/index.md index f5cfc52..db36a63 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,10 +9,11 @@ See more information at [Flightradar24's terms and conditions](https://www.fligh [![Python Package](https://github.com/JeanExtreme002/FlightRadarAPI/workflows/Python%20Package/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) [![Node.js Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/node-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) [![Go Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/go-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) -[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi) [![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) [![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) diff --git a/go/README.md b/go/README.md index 4b1309f..17cabf9 100644 --- a/go/README.md +++ b/go/README.md @@ -6,10 +6,11 @@ This SDK should only be used for your own educational purposes. If you are inter **Official FR24 API**: https://fr24api.flightradar24.com/ [![Go Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/go-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) -[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi) [![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) [![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) diff --git a/nodejs/README.md b/nodejs/README.md index 6da5aca..d290dd5 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -6,10 +6,11 @@ This SDK should only be used for your own educational purposes. If you are inter **Official FR24 API**: https://fr24api.flightradar24.com/ [![Node.js Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/node-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) -[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi) [![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) [![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) diff --git a/python/README.md b/python/README.md index aa6e962..4f045ef 100644 --- a/python/README.md +++ b/python/README.md @@ -6,10 +6,11 @@ This SDK should only be used for your own educational purposes. If you are inter **Official FR24 API**: https://fr24api.flightradar24.com/ [![Python Package](https://github.com/JeanExtreme002/FlightRadarAPI/workflows/Python%20Package/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) -[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi) [![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) [![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) From be6ab2d02fbc674385ae50f8f246e237453b50d0 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 21:38:04 -0300 Subject: [PATCH 07/13] fix: harden the edges a public surface exposes Eight findings from the review on the pull request, each reproduced before it was changed: - CheckInfo reported an unknown field only when Go's randomised map iteration put it before a criterion that fails: 26 of 200 runs on the same input. Every criterion is now validated before any is evaluated. - GetFlightDetails and GetHistoryData take a pointer, so nil is valid at compile time and panicked instead of returning an error. - A body with data spliced after the JSON was accepted, where the Python and Node.js parsers reject it: Decode reads the first value only. - Network failures did not wrap ErrFlightRadar, contradicting the taxonomy documented on that sentinel. They now wrap both it and the transport's own cause, which the retry policy still reads. - A negative Client.Timeout skipped the deadline entirely instead of falling back to the default the field documents. - New() panicked when the process had replaced http.DefaultTransport, which mocking libraries legitimately do. - SleepFor panicked on the largest Jitter a Duration can hold, and could wrap round when adding it to the delay. - Missing aircraft images stayed nil where the other ports default to an empty list, which changes both a CheckInfo comparison and the JSON. --- go/flightradarapi/api.go | 8 +++ go/flightradarapi/api_test.go | 14 ++++ go/flightradarapi/entities_test.go | 53 +++++++++++++++ go/flightradarapi/flight.go | 47 +++++++++---- go/flightradarapi/parsers.go | 8 +++ go/flightradarapi/parsers_test.go | 18 +++++ go/flightradarapi/request.go | 41 ++++++++++-- go/flightradarapi/request_test.go | 103 +++++++++++++++++++++++++++++ 8 files changed, 272 insertions(+), 20 deletions(-) diff --git a/go/flightradarapi/api.go b/go/flightradarapi/api.go index e93f501..a4323f3 100644 --- a/go/flightradarapi/api.go +++ b/go/flightradarapi/api.go @@ -395,6 +395,10 @@ func (c *Client) GetCountryFlag(ctx context.Context, country string) (*Image, er // GetFlightDetails returns the details payload of a flight. func (c *Client) GetFlightDetails(ctx context.Context, flight *Flight) (map[string]any, error) { + if flight == nil { + return nil, fmt.Errorf("%w: no flight given", ErrFlightRadar) + } + response, err := c.client.requestStandalone(ctx, c.endpoints.flightDataURL(flight.ID), requestOptions{ headers: jsonHeaders, timeout: c.Timeout, @@ -571,6 +575,10 @@ func (c *Client) SetFlightTrackerConfig(config *FlightTrackerConfig, values map[ // GetHistoryData downloads the historical data of a flight. fileType must be // "CSV" or "KML". Requires a premium account. func (c *Client) GetHistoryData(ctx context.Context, flight *Flight, fileType string, timestamp int64) (string, error) { + if flight == nil { + return "", fmt.Errorf("%w: no flight given", ErrFlightRadar) + } + headers, err := c.authHeaders() if err != nil { diff --git a/go/flightradarapi/api_test.go b/go/flightradarapi/api_test.go index 365d1c7..e2d6459 100644 --- a/go/flightradarapi/api_test.go +++ b/go/flightradarapi/api_test.go @@ -1329,3 +1329,17 @@ func TestAJarOnTheGivenClientIsIgnored(t *testing.T) { t.Errorf("got %v, want the cookie sent exactly once", received) } } + +func TestFlightEndpointsRefuseANilFlight(t *testing.T) { + // The argument is a pointer, so nil is valid at compile time; it must come + // back as the package's error rather than a panic. + client := newTestClient(t, http.NewServeMux()) + ctx := context.Background() + + if _, err := client.GetFlightDetails(ctx, nil); !errors.Is(err, ErrFlightRadar) { + t.Errorf("GetFlightDetails: got %v, want an error", err) + } + if _, err := client.GetHistoryData(ctx, nil, "CSV", 0); !errors.Is(err, ErrFlightRadar) { + t.Errorf("GetHistoryData: got %v, want an error", err) + } +} diff --git a/go/flightradarapi/entities_test.go b/go/flightradarapi/entities_test.go index 1ed4e7f..6fb252b 100644 --- a/go/flightradarapi/entities_test.go +++ b/go/flightradarapi/entities_test.go @@ -569,3 +569,56 @@ func TestConstructorsReadGoNumericTypes(t *testing.T) { t.Errorf("got %v for a bool, want nil", *number) } } + +func TestCheckInfoReportsAnUnknownFieldWhateverTheOrder(t *testing.T) { + // Map iteration is randomised: reporting as it went raised this error only + // when the unknown key happened to come before a criterion that fails. + flight := newFlight("x", feedRow()) + + for range 200 { + matched, err := flight.CheckInfo(map[string]any{ + "airline_icao": "NOPE", "campo_inexistente": 1, + }) + + if err == nil { + t.Fatal("an unknown field must be reported every time, not sometimes") + } + if matched { + t.Fatal("a rejected criteria set must not report a match") + } + } +} + +func TestCheckInfoReportsANonNumericBoundWhateverTheOrder(t *testing.T) { + flight := newFlight("x", feedRow()) + + for range 200 { + if _, err := flight.CheckInfo(map[string]any{ + "airline_icao": "NOPE", "min_altitude": "muito alto", + }); err == nil { + t.Fatal("a non-numeric bound must be reported every time") + } + } +} + +func TestSetFlightDetailsDefaultsAircraftImagesToAList(t *testing.T) { + // The Python port does aircraft.get("images", []), and the field is `any`: + // a nil compares differently in CheckInfo and marshals as null. + flight := newFlight("x", feedRow()) + flight.SetFlightDetails(map[string]any{"aircraft": map[string]any{}}) + + images, ok := flight.Details.AircraftImages.([]any) + + if !ok || len(images) != 0 { + t.Errorf("got %#v, want an empty list", flight.Details.AircraftImages) + } + + // A payload that carries images keeps its own shape. + flight.SetFlightDetails(map[string]any{ + "aircraft": map[string]any{"images": map[string]any{"large": []any{"a"}}}, + }) + + if _, ok := flight.Details.AircraftImages.(map[string]any); !ok { + t.Errorf("got %#v, want the payload preserved", flight.Details.AircraftImages) + } +} diff --git a/go/flightradarapi/flight.go b/go/flightradarapi/flight.go index 0bfa180..94891a3 100644 --- a/go/flightradarapi/flight.go +++ b/go/flightradarapi/flight.go @@ -341,38 +341,51 @@ func detailValue(field reflect.Value) any { func (f *Flight) CheckInfo(criteria map[string]any) (bool, error) { fields := f.fields() + type comparison struct { + name string + prefix string + wanted any + } + + // Every criterion is validated before any is evaluated. Map iteration is + // randomised, so reporting as we went would raise the unknown-field error + // only when that key happened to come before a criterion that fails. + comparisons := make([]comparison, 0, len(criteria)) + for key, wanted := range criteria { name, prefix := key, "" if strings.HasPrefix(key, "min_") || strings.HasPrefix(key, "max_") { name, prefix = key[4:], key[:3] } - - actual, known := fields[name] - - if !known { + if _, known := fields[name]; !known { return false, fmt.Errorf("%w: unknown flight field %q", ErrFlightRadar, key) } + if prefix != "" && toNumber(wanted) == nil { + return false, fmt.Errorf("%w: %q needs a numeric value, got %v", ErrFlightRadar, key, wanted) + } + comparisons = append(comparisons, comparison{name: name, prefix: prefix, wanted: wanted}) + } + + for _, check := range comparisons { + actual := fields[check.name] - if prefix == "" { - if !equalValues(wanted, actual) { + if check.prefix == "" { + if !equalValues(check.wanted, actual) { return false, nil } continue } - wantedNumber, actualNumber := toNumber(wanted), toNumber(actual) + wantedNumber, actualNumber := toNumber(check.wanted), toNumber(actual) - if wantedNumber == nil { - return false, fmt.Errorf("%w: %q needs a numeric value, got %v", ErrFlightRadar, key, wanted) - } if actualNumber == nil { return false, nil } - if prefix == "min" && *actualNumber < *wantedNumber { + if check.prefix == "min" && *actualNumber < *wantedNumber { return false, nil } - if prefix == "max" && *actualNumber > *wantedNumber { + if check.prefix == "max" && *actualNumber > *wantedNumber { return false, nil } } @@ -435,6 +448,14 @@ func (f *Flight) SetFlightDetails(flightDetails map[string]any) { originCountry := getMap(originPosition, "country") originTimezone := getMap(origin, "timezone") + // The Python port defaults this to an empty list; the field is `any`, so a + // nil would compare differently in CheckInfo and marshal as null. + images, hasImages := aircraft["images"] + + if !hasImages { + images = []any{} + } + history := getMap(flightDetails, "flightHistory") status := getMap(flightDetails, "status") @@ -442,7 +463,7 @@ func (f *Flight) SetFlightDetails(flightDetails map[string]any) { AircraftAge: getString(aircraft, "age"), AircraftCountryID: getNumber(aircraft, "countryId"), AircraftHistory: getSlice(history, "aircraft"), - AircraftImages: aircraft["images"], + AircraftImages: images, AircraftModel: getString(getMap(aircraft, "model"), "text"), AirlineName: getString(airline, "name"), diff --git a/go/flightradarapi/parsers.go b/go/flightradarapi/parsers.go index 219471a..42163ba 100644 --- a/go/flightradarapi/parsers.go +++ b/go/flightradarapi/parsers.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "io" "math" "reflect" "regexp" @@ -229,6 +230,13 @@ func parseAirportsJSON(payload []byte, countries []Country) []*Airport { return []*Airport{} } + // Decode stops at the first value, so a truncated or spliced body would + // otherwise pass as valid. The Python and Node.js parsers reject it. + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + log().Warn("parseAirportsJSON: response carries data past the JSON body — FR24 feed may have changed") + return []*Airport{} + } + rows, ok := data["rows"].([]any) if !ok { diff --git a/go/flightradarapi/parsers_test.go b/go/flightradarapi/parsers_test.go index 14c8bcd..f18ad31 100644 --- a/go/flightradarapi/parsers_test.go +++ b/go/flightradarapi/parsers_test.go @@ -448,3 +448,21 @@ func repeat(text string, times int) string { } return string(result) } + +func TestParseAirportsJSONRejectsDataPastTheJSONBody(t *testing.T) { + // Decode reads the first value only, so a spliced body used to pass. + payload := []byte(`{"rows":[{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain",` + + `"lat":1,"lon":2,"alt":3}]}trailing garbage`) + + if airports := parseAirportsJSON(payload, nil); len(airports) != 0 { + t.Errorf("got %d airports, want the body rejected", len(airports)) + } + + // Trailing whitespace is not garbage. + clean := []byte(`{"rows":[{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain",` + + `"lat":1,"lon":2,"alt":3}]}` + "\n\n ") + + if airports := parseAirportsJSON(clean, nil); len(airports) != 1 { + t.Errorf("got %d airports, want the body accepted", len(airports)) + } +} diff --git a/go/flightradarapi/request.go b/go/flightradarapi/request.go index ae74ef8..708f7b7 100644 --- a/go/flightradarapi/request.go +++ b/go/flightradarapi/request.go @@ -218,13 +218,30 @@ func (p *RetryPolicy) SleepFor(attemptIndex int) time.Duration { if capped := float64(p.MaxDelay); capped > 0 && delay > capped { delay = capped } + if delay >= float64(math.MaxInt64) { + return time.Duration(math.MaxInt64) + } - jitter := time.Duration(0) + jitter := int64(0) if p.Jitter > 0 { - jitter = time.Duration(rand.Int63n(int64(p.Jitter) + 1)) + span := int64(p.Jitter) + + // The endpoint is included by sampling one past the span, which only + // the largest Duration there is cannot afford. + if span < math.MaxInt64 { + span++ + } + jitter = rand.Int63n(span) + } + + total := int64(delay) + jitter + + // Two values that each fit can still overflow together. + if total < 0 { + return time.Duration(math.MaxInt64) } - return time.Duration(delay) + jitter + return time.Duration(total) } // isTransient reports whether a failure is worth retrying. @@ -344,7 +361,13 @@ func bankRedirectCookies(request *http.Request, via []*http.Request) error { // newHTTPClient builds a client that impersonates the profile and leaves // content decoding to this package. func newHTTPClient(profile TLSProfile) *http.Client { - transport := http.DefaultTransport.(*http.Transport).Clone() + // A comma-ok assertion, because http.DefaultTransport is an interface an + // application (or a mocking library) is free to replace. + transport := &http.Transport{Proxy: http.ProxyFromEnvironment} + + if standard, ok := http.DefaultTransport.(*http.Transport); ok { + transport = standard.Clone() + } // Decoding is ours: the transport would expand a bomb before any budget // could see it. @@ -488,7 +511,9 @@ func (c *apiClient) do(ctx context.Context, target string, options requestOption timeout := options.timeout - if timeout == 0 { + // Zero or less means the default, as Options documents: a negative value + // must not leave the request unbounded. + if timeout <= 0 { timeout = DefaultTimeout } if timeout > 0 { @@ -529,7 +554,9 @@ func (c *apiClient) do(ctx context.Context, target string, options requestOption response, err := c.httpClient.Do(request) if err != nil { - return nil, err + // Wrapped twice: callers match the package sentinel, and the retry + // policy still reads the transport's own cause underneath. + return nil, fmt.Errorf("%w: %w", ErrFlightRadar, err) } defer response.Body.Close() @@ -548,7 +575,7 @@ func (c *apiClient) do(ctx context.Context, target string, options requestOption } if err != nil { - return nil, err + return nil, fmt.Errorf("%w: %w", ErrFlightRadar, err) } if len(received) > maxDownloadBytes { return nil, limitError("response body from %s is larger than the %d byte download limit", diff --git a/go/flightradarapi/request_test.go b/go/flightradarapi/request_test.go index 73bfbad..9b8c2ad 100644 --- a/go/flightradarapi/request_test.go +++ b/go/flightradarapi/request_test.go @@ -9,6 +9,7 @@ import ( "errors" "io" "log/slog" + "math" "net/http" "net/http/httptest" "net/url" @@ -1248,3 +1249,105 @@ func TestTheLimitErrorNamesTheBudget(t *testing.T) { t.Errorf("got %q, want the 10 byte budget named", err) } } + +func TestATransportFailureCarriesThePackageSentinel(t *testing.T) { + // The taxonomy documented on ErrFlightRadar says every error here wraps it. + _, err := testClient().request(context.Background(), "http://127.0.0.1:9/", requestOptions{}) + + if !errors.Is(err, ErrFlightRadar) { + t.Errorf("got %v, want it to match ErrFlightRadar", err) + } + + // The transport's own cause has to survive, or the retry policy goes blind. + var urlErr *url.Error + + if !errors.As(err, &urlErr) { + t.Errorf("got %v, want the url.Error underneath", err) + } + if !isTransient(err) { + t.Error("a network failure must still read as transient") + } +} + +// deadlineRecorder reads the deadline of the outgoing request, which is where +// the client-side timeout lives: it never travels to the server. +type deadlineRecorder struct { + base http.RoundTripper + deadline time.Time + set bool +} + +func (d *deadlineRecorder) RoundTrip(request *http.Request) (*http.Response, error) { + d.deadline, d.set = request.Context().Deadline() + return d.base.RoundTrip(request) +} + +func TestANegativeTimeoutFallsBackToTheDefault(t *testing.T) { + // Client.Timeout is public and documented as "zero or less means the + // default", so a negative value must not leave the request unbounded. + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + recorder := &deadlineRecorder{base: newHTTPClient(Chrome136Profile()).Transport} + client := newAPIClient(&http.Client{Transport: recorder}, nil) + + for name, timeout := range map[string]time.Duration{"negative": -time.Second, "zero": 0} { + recorder.set = false + + if _, err := client.request(context.Background(), server.URL, requestOptions{timeout: timeout}); err != nil { + t.Fatalf("%s: unexpected error: %v", name, err) + } + if !recorder.set { + t.Fatalf("%s: the request went out with no deadline at all", name) + } + if remaining := time.Until(recorder.deadline); remaining > DefaultTimeout { + t.Errorf("%s: got %v left, want at most the %v default", name, remaining, DefaultTimeout) + } + } +} + +func TestSleepForSurvivesTheLargestJitter(t *testing.T) { + // Every field is public: the largest Duration there is must not panic. + policy := &RetryPolicy{MaxAttempts: 2, BaseDelay: time.Second, Jitter: math.MaxInt64} + + if delay := policy.SleepFor(0); delay < 0 { + t.Errorf("got %v, want a usable delay", delay) + } + + // Nor may the sum of two values that each fit wrap round. + huge := &RetryPolicy{MaxAttempts: 2, BaseDelay: math.MaxInt64, Jitter: math.MaxInt64} + + if delay := huge.SleepFor(0); delay < 0 { + t.Errorf("got %v, want the sum clamped", delay) + } +} + +type replacedRoundTripper struct{} + +func (replacedRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("not used") +} + +func TestNewSurvivesAReplacedDefaultTransport(t *testing.T) { + // Mocking libraries swap http.DefaultTransport; a type assertion on it + // turned New() into a panic. + original := http.DefaultTransport + http.DefaultTransport = replacedRoundTripper{} + t.Cleanup(func() { http.DefaultTransport = original }) + + client := New() + + if client == nil { + t.Fatal("no client") + } + + transport, ok := newHTTPClient(Chrome136Profile()).Transport.(*http.Transport) + + if !ok { + t.Fatal("expected this package to build its own transport") + } + if !transport.DisableCompression { + t.Error("the fallback transport must still leave decoding to this package") + } +} From 5dfbfe483df4ce6c31756b446dafbb87902801a0 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 22:03:41 -0300 Subject: [PATCH 08/13] fix: retry a body lost mid-download, and widen two value readers A connection dropped partway through a response was classified as permanent, because the body is read after Do returns and so carries no *url.Error of its own: against a server that promises 5000 bytes and hangs up after 6, a policy asking for three attempts made one. The Python port retries the same failure, where curl_cffi reads the body inside the call. The read failure is now wrapped the way the transport wraps its own. Two readers were narrower than they read: nativeNumber covered every numeric kind except float64, so a defined type over float64 was refused where one over int64 worked, and getString ignored json.Number, which this package itself produces when it decodes with UseNumber. --- go/flightradarapi/entities_test.go | 22 ++++++++++++ go/flightradarapi/parsers.go | 2 +- go/flightradarapi/parsers_test.go | 21 ++++++++++++ go/flightradarapi/request.go | 6 +++- go/flightradarapi/request_test.go | 54 ++++++++++++++++++++++++++++++ go/flightradarapi/values.go | 9 ++++- 6 files changed, 111 insertions(+), 3 deletions(-) diff --git a/go/flightradarapi/entities_test.go b/go/flightradarapi/entities_test.go index 6fb252b..9acaa16 100644 --- a/go/flightradarapi/entities_test.go +++ b/go/flightradarapi/entities_test.go @@ -622,3 +622,25 @@ func TestSetFlightDetailsDefaultsAircraftImagesToAList(t *testing.T) { t.Errorf("got %#v, want the payload preserved", flight.Details.AircraftImages) } } + +func TestCheckInfoAcceptsDefinedNumericTypes(t *testing.T) { + // A defined type over float64 reaches the reflection path, where the + // float64 kind was missing while int64 and float32 worked. + type feet float64 + type knots int64 + type ratio float32 + + flight := newFlight("x", feedRow()) + + for name, criteria := range map[string]map[string]any{ + "float64 underlying": {"min_altitude": feet(6700)}, + "int64 underlying": {"min_altitude": knots(6700)}, + "float32 underlying": {"min_altitude": ratio(6700)}, + } { + matched, err := flight.CheckInfo(criteria) + + if err != nil || !matched { + t.Errorf("%s: got %v (err=%v), want a match", name, matched, err) + } + } +} diff --git a/go/flightradarapi/parsers.go b/go/flightradarapi/parsers.go index 42163ba..57fd6b8 100644 --- a/go/flightradarapi/parsers.go +++ b/go/flightradarapi/parsers.go @@ -185,7 +185,7 @@ func nativeNumber(value any) *float64 { number = float64(reflected.Int()) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: number = float64(reflected.Uint()) - case reflect.Float32: + case reflect.Float32, reflect.Float64: number = reflected.Float() default: return nil diff --git a/go/flightradarapi/parsers_test.go b/go/flightradarapi/parsers_test.go index f18ad31..2bd0718 100644 --- a/go/flightradarapi/parsers_test.go +++ b/go/flightradarapi/parsers_test.go @@ -6,6 +6,7 @@ import ( "math" "os" "path/filepath" + "strings" "testing" ) @@ -466,3 +467,23 @@ func TestParseAirportsJSONRejectsDataPastTheJSONBody(t *testing.T) { t.Errorf("got %d airports, want the body accepted", len(airports)) } } + +func TestTextFieldsReadAPayloadDecodedWithUseNumber(t *testing.T) { + // This package decodes the airports feed that way, and the exported + // constructors take a map the caller may have decoded the same way. + decoder := json.NewDecoder(strings.NewReader(`{"age": 12, "name": "Lukla"}`)) + decoder.UseNumber() + + var payload map[string]any + + if err := decoder.Decode(&payload); err != nil { + t.Fatal(err) + } + + if got := getString(payload, "age"); got != "12" { + t.Errorf("got %q, want the number rendered as text", got) + } + if got := getString(payload, "name"); got != "Lukla" { + t.Errorf("got %q, want Lukla", got) + } +} diff --git a/go/flightradarapi/request.go b/go/flightradarapi/request.go index 708f7b7..df52d5e 100644 --- a/go/flightradarapi/request.go +++ b/go/flightradarapi/request.go @@ -575,7 +575,11 @@ func (c *apiClient) do(ctx context.Context, target string, options requestOption } if err != nil { - return nil, fmt.Errorf("%w: %w", ErrFlightRadar, err) + // Wrapped the way the transport wraps its own failures: the body is + // read after Do returns, so a connection lost mid-body would otherwise + // reach the retry policy as a bare read error and never be retried. + return nil, fmt.Errorf("%w: %w", ErrFlightRadar, + &url.Error{Op: method, URL: finalURL.String(), Err: err}) } if len(received) > maxDownloadBytes { return nil, limitError("response body from %s is larger than the %d byte download limit", diff --git a/go/flightradarapi/request_test.go b/go/flightradarapi/request_test.go index 9b8c2ad..9940b75 100644 --- a/go/flightradarapi/request_test.go +++ b/go/flightradarapi/request_test.go @@ -7,9 +7,11 @@ import ( "compress/zlib" "context" "errors" + "fmt" "io" "log/slog" "math" + "net" "net/http" "net/http/httptest" "net/url" @@ -1351,3 +1353,55 @@ func TestNewSurvivesAReplacedDefaultTransport(t *testing.T) { t.Error("the fallback transport must still leave decoding to this package") } } + +// truncatingListener answers with a Content-Length it does not honour, then +// hangs up — the shape of a connection FR24 drops mid-body. +func truncatingListener(t *testing.T, attempts *atomic.Int32) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + + go func() { + for { + conn, err := listener.Accept() + + if err != nil { + return + } + attempts.Add(1) + fmt.Fprint(conn, "HTTP/1.1 200 OK\r\nContent-Length: 5000\r\n\r\nabcdef") + conn.Close() + } + }() + return "http://" + listener.Addr().String() +} + +func TestABodyLostMidDownloadIsRetried(t *testing.T) { + // The body is read after Do returns, so this failure carries no *url.Error + // of its own and used to be classified as permanent. + var attempts atomic.Int32 + target := truncatingListener(t, &attempts) + + client := newAPIClient(newHTTPClient(Chrome136Profile()), &RetryPolicy{ + MaxAttempts: 3, BaseDelay: time.Millisecond, + }) + + _, err := client.request(context.Background(), target, requestOptions{}) + + if err == nil { + t.Fatal("expected the truncated body to fail") + } + if !isTransient(err) { + t.Errorf("got %v, want a transient failure", err) + } + if attempts.Load() != 3 { + t.Errorf("got %d attempts, want the policy's 3", attempts.Load()) + } + if !errors.Is(err, ErrFlightRadar) { + t.Errorf("got %v, want it to match ErrFlightRadar", err) + } +} diff --git a/go/flightradarapi/values.go b/go/flightradarapi/values.go index 26afa99..cc1faf9 100644 --- a/go/flightradarapi/values.go +++ b/go/flightradarapi/values.go @@ -1,6 +1,9 @@ package flightradarapi -import "strconv" +import ( + "encoding/json" + "strconv" +) // DefaultText is the placeholder the Get* formatters return for a value the // feed did not send. @@ -49,6 +52,10 @@ func getString(source map[string]any, key string) string { switch typed := value.(type) { case string: return typed + case json.Number: + // A payload decoded with UseNumber carries these where a plain decode + // would carry a float64. + return typed.String() case float64: return formatNumber(typed) case bool: From 56c4fdfec9aadb10baf61027562ebca71db5cd37 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 22:08:52 -0300 Subject: [PATCH 09/13] fix: send every in-scope cookie, and never sleep on a bad delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jar collapsed same-named cookies to one, so a request to /data/... carried the root token where FR24 had scoped a different value to that path. RFC 6265 5.4 asks for every match, longest path first and oldest first among equal paths, which is also what the cookie jar behind the Python port does. get() still answers with the newest re-issue: it says which token is current, not what to put on the wire. A negative BaseDelay reached the overflow guard added with the last round of fixes and came back as the longest sleep a Duration can hold — 292 years before the first retry. The zero BaseDelay that turns into NaN once the doubling overflows landed in the same place. --- go/flightradarapi/cookies.go | 34 +++++++++++-------------------- go/flightradarapi/cookies_test.go | 31 +++++++++++++++++++++++++--- go/flightradarapi/request.go | 8 +++++++- go/flightradarapi/request_test.go | 21 +++++++++++++++++++ 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/go/flightradarapi/cookies.go b/go/flightradarapi/cookies.go index 043b1d4..010debe 100644 --- a/go/flightradarapi/cookies.go +++ b/go/flightradarapi/cookies.go @@ -1,9 +1,11 @@ package flightradarapi import ( + "cmp" "math" "net/http" "net/url" + "slices" "strconv" "strings" "sync" @@ -181,30 +183,18 @@ func (j *cookieJar) matching(target *url.URL) []*storedCookie { } } - // Oldest first so the newest of a same-named pair wins, the rule get() - // uses. Collapsing to one per name is a deliberate limit of a flat jar. - sortByStoredAt(matches) - - seen := make(map[string]int, len(matches)) - deduped := make([]*storedCookie, 0, len(matches)) - - for _, cookie := range matches { - if index, ok := seen[cookie.name]; ok { - deduped[index] = cookie - continue + // RFC 6265 5.4: every cookie whose path matches goes out, the longest path + // first, and among equal paths the one stored earliest. Keeping only one + // per name would hand the server the root cookie where it scoped a + // different value to this path. + slices.SortFunc(matches, func(a, b *storedCookie) int { + if byPath := cmp.Compare(len(b.path), len(a.path)); byPath != 0 { + return byPath } - seen[cookie.name] = len(deduped) - deduped = append(deduped, cookie) - } - return deduped -} + return cmp.Compare(a.storedAt, b.storedAt) + }) -func sortByStoredAt(cookies []*storedCookie) { - for i := 1; i < len(cookies); i++ { - for j := i; j > 0 && cookies[j-1].storedAt > cookies[j].storedAt; j-- { - cookies[j-1], cookies[j] = cookies[j], cookies[j-1] - } - } + return matches } // parseSetCookie parses one Set-Cookie header into a cookie record, or nil when diff --git a/go/flightradarapi/cookies_test.go b/go/flightradarapi/cookies_test.go index 788dd5b..1ba48dd 100644 --- a/go/flightradarapi/cookies_test.go +++ b/go/flightradarapi/cookies_test.go @@ -119,7 +119,8 @@ func TestJarSkipsASecureCookieOverPlainHTTP(t *testing.T) { } } -func TestJarNewestValueOfANameWins(t *testing.T) { +func TestJarNewestValueOfANameWinsForGet(t *testing.T) { + // get() answers "which token is current", so the newest re-issue wins. jar := newCookieJar() jar.store(mustURL(t, "https://www.flightradar24.com/data/airlines"), []string{"token=old"}) jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"token=new; Path=/"}) @@ -127,8 +128,32 @@ func TestJarNewestValueOfANameWins(t *testing.T) { if value, _ := jar.get("token"); value != "new" { t.Errorf("got %q, want new", value) } - if header := jar.header(mustURL(t, "https://www.flightradar24.com/data/airlines")); header != "token=new" { - t.Errorf("got %q, want token=new", header) +} + +func TestJarSendsEverySameNamedCookieLongestPathFirst(t *testing.T) { + // RFC 6265 5.4. Collapsing to one per name handed the server the root + // cookie where FR24 had scoped a different value to this path. + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/data/airlines"), []string{"token=scoped"}) + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"token=root; Path=/"}) + + if header := jar.header(mustURL(t, "https://www.flightradar24.com/data/airlines")); header != "token=scoped; token=root" { + t.Errorf("got %q, want the path-scoped cookie first", header) + } + + // Outside the scoped path only the root one applies. + if header := jar.header(mustURL(t, "https://www.flightradar24.com/other")); header != "token=root" { + t.Errorf("got %q, want only the root cookie", header) + } +} + +func TestJarOrdersEqualPathsByAge(t *testing.T) { + jar := newCookieJar() + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"first=1; Path=/"}) + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"second=2; Path=/"}) + + if header := jar.header(mustURL(t, "https://www.flightradar24.com/")); header != "first=1; second=2" { + t.Errorf("got %q, want the oldest first", header) } } diff --git a/go/flightradarapi/request.go b/go/flightradarapi/request.go index df52d5e..4eabc47 100644 --- a/go/flightradarapi/request.go +++ b/go/flightradarapi/request.go @@ -174,7 +174,7 @@ func decompressBrotli(data []byte, limit int) ([]byte, error) { type RetryPolicy struct { // MaxAttempts is the total number of attempts, including the first. MaxAttempts int - // BaseDelay is the first backoff sleep. + // BaseDelay is the first backoff sleep. Zero or less means no wait. BaseDelay time.Duration // MaxDelay caps the exponential backoff. Zero means uncapped. MaxDelay time.Duration @@ -215,6 +215,12 @@ func (p *RetryPolicy) validate() error { func (p *RetryPolicy) SleepFor(attemptIndex int) time.Duration { delay := float64(p.BaseDelay) * math.Pow(2, float64(attemptIndex)) + // A negative BaseDelay, or the NaN a zero one produces once the doubling + // overflows, would otherwise reach the overflow guard below and come back + // as the longest sleep a Duration can hold. + if math.IsNaN(delay) || delay < 0 { + delay = 0 + } if capped := float64(p.MaxDelay); capped > 0 && delay > capped { delay = capped } diff --git a/go/flightradarapi/request_test.go b/go/flightradarapi/request_test.go index 9940b75..12714e6 100644 --- a/go/flightradarapi/request_test.go +++ b/go/flightradarapi/request_test.go @@ -1405,3 +1405,24 @@ func TestABodyLostMidDownloadIsRetried(t *testing.T) { t.Errorf("got %v, want it to match ErrFlightRadar", err) } } + +func TestSleepForRefusesToTurnABadDelayIntoAnEternity(t *testing.T) { + // New() no longer validates the policy, so a public field can arrive + // negative — and the overflow guard used to read that as an overflow and + // return the longest sleep a Duration can hold. + cases := map[string]*RetryPolicy{ + "negative base delay": {MaxAttempts: 3, BaseDelay: -time.Second}, + "negative everything": {MaxAttempts: 3, BaseDelay: -time.Second, MaxDelay: -time.Second}, + "doubling overflows": {MaxAttempts: 3, BaseDelay: 0}, + } + + for name, policy := range cases { + for _, attempt := range []int{0, 3, 2000} { + delay := policy.SleepFor(attempt) + + if delay < 0 || delay > time.Minute { + t.Errorf("%s, attempt %d: got %v, want no wait", name, attempt, delay) + } + } + } +} From d62331f3ead05a4714a314a03b9fac5005639088 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 22:25:57 -0300 Subject: [PATCH 10/13] test: fold two duplicated bounds tests into the parity one GetBounds was asserted twice with the same call and different constants, and the "box surrounds the point" test was subsumed by the one comparing all four values against the numbers the Python suite pins. Its two assertions moved into the survivor rather than being dropped: they are what catches an expected-value fixture updated the wrong way. Reordering the fields and "fixing" the fixture to match leaves the exact comparison passing and fails on the shape. --- go/flightradarapi/api_test.go | 45 ++++++++++------------------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/go/flightradarapi/api_test.go b/go/flightradarapi/api_test.go index e2d6459..2942879 100644 --- a/go/flightradarapi/api_test.go +++ b/go/flightradarapi/api_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "math" "net/http" "net/http/cookiejar" @@ -311,37 +310,6 @@ func TestGetZonesReturnsACopy(t *testing.T) { } } -func TestGetBoundsRendersTheZone(t *testing.T) { - client := newTestClient(t, http.NewServeMux()) - bounds := client.GetBounds(Zone{TLY: 72.57, TLX: -16.96, BRY: 33.57, BRX: 53.05}) - - if bounds != "72.57,33.57,-16.96,53.05" { - t.Errorf("got %q", bounds) - } -} - -func TestGetBoundsByPointSurroundsThePoint(t *testing.T) { - client := newTestClient(t, http.NewServeMux()) - bounds := client.GetBoundsByPoint(52.567774, 13.282827, 2000) - parts := strings.Split(bounds, ",") - - if len(parts) != 4 { - t.Fatalf("got %q, want four values", bounds) - } - - var north, south, west, east float64 - - if _, err := fmt.Sscanf(bounds, "%f,%f,%f,%f", &north, &south, &west, &east); err != nil { - t.Fatal(err) - } - if !(south < 52.567774 && 52.567774 < north) || !(west < 13.282827 && 13.282827 < east) { - t.Errorf("got %q, want a box around the point", bounds) - } - if north-south > 0.1 || east-west > 0.1 { - t.Errorf("got %q, want a box of about 4 km across", bounds) - } -} - // --- flights --- func TestGetFlightsParsesTheFeed(t *testing.T) { @@ -1162,6 +1130,8 @@ func TestGetBoundsByPointAgreesWithThePythonPort(t *testing.T) { t.Fatalf("got %q, want four values", bounds) } + corners := make([]float64, len(parts)) + for index, part := range parts { got, err := strconv.ParseFloat(part, 64) @@ -1171,6 +1141,17 @@ func TestGetBoundsByPointAgreesWithThePythonPort(t *testing.T) { if math.Abs(got-expected[index]) > tolerance { t.Errorf("value %d: got %v, want %v (within %v)", index, got, expected[index], tolerance) } + corners[index] = got + } + + // The values above are a fixture; these two say what they mean. + north, south, west, east := corners[0], corners[1], corners[2], corners[3] + + if !(south < 52.567967 && 52.567967 < north) || !(west < 13.282644 && 13.282644 < east) { + t.Errorf("got %q, want a box around the point", bounds) + } + if north-south > 0.1 || east-west > 0.1 { + t.Errorf("got %q, want a box of about 4 km across", bounds) } } From af5570b5270ebf1a9760bf6a2171161e990983af Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 22:33:22 -0300 Subject: [PATCH 11/13] fix: keep the feed's order, and close four gaps the taxonomy left open GetFlights walked a Go map, whose iteration order is randomised, so the same feed answered in a different order on every call where the Python and Node.js ports keep the order FR24 sent. The keys are now read from the body itself. Four smaller ones, each reproduced first: - a body of "null" unmarshalled into a nil map with no error, so every key read as missing instead of the caller seeing the failure - Content-Type was matched case-sensitively, though a media type is not - Content-Encoding split across header fields decoded only its first layer and left the body compressed - cancellation during the retry backoff returned the bare context error, outside the taxonomy every other failure path follows A negative flightLimit, page or limit is no longer rewritten as the default: only zero selects it, and anything else goes to FR24 as given, which is what the sibling ports do. The workflow now also runs when python/FlightRadarAPI changes, since ports_test.go reads those files to detect drift and could not see a change that never triggered it. --- .github/workflows/go-package.yml | 4 ++ docs/go.md | 6 ++- go/flightradarapi/api.go | 65 +++++++++++++++++++++---- go/flightradarapi/api_test.go | 79 ++++++++++++++++++++++++++++++ go/flightradarapi/request.go | 21 ++++++-- go/flightradarapi/request_test.go | 81 +++++++++++++++++++++++++++++++ 6 files changed, 240 insertions(+), 16 deletions(-) diff --git a/.github/workflows/go-package.yml b/.github/workflows/go-package.yml index a26e63f..4ebb989 100644 --- a/.github/workflows/go-package.yml +++ b/.github/workflows/go-package.yml @@ -6,10 +6,14 @@ on: - main paths: - 'go/**' + # ports_test.go reads these to keep the three SDKs aligned, so a change + # there has to run this workflow or the drift goes unnoticed. + - 'python/FlightRadarAPI/**' - '.github/workflows/go-package.yml' pull_request: paths: - 'go/**' + - 'python/FlightRadarAPI/**' - '.github/workflows/go-package.yml' schedule: - cron: '0 0 */7 * *' diff --git a/docs/go.md b/docs/go.md index 95b3a2f..6997bfa 100644 --- a/docs/go.md +++ b/docs/go.md @@ -16,7 +16,11 @@ go get github.com/JeanExtreme002/FlightRadarAPI/go@latest Import the package and create a client: ```go -import "github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi" +import ( + "time" + + "github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi" +) client := flightradarapi.New() diff --git a/go/flightradarapi/api.go b/go/flightradarapi/api.go index a4323f3..5539b5a 100644 --- a/go/flightradarapi/api.go +++ b/go/flightradarapi/api.go @@ -1,6 +1,7 @@ package flightradarapi import ( + "bytes" "context" "encoding/json" "fmt" @@ -214,12 +215,13 @@ func (c *Client) GetAirport(ctx context.Context, code string, details bool) (*Ai // GetAirportDetails returns the full airport payload, with up to flightLimit // flights from the given page of results. Zero means what the Python and -// Node.js ports default to: 100 flights, first page. +// Node.js ports default to: 100 flights, first page. Any other value is sent as +// given, so FR24 rejects a nonsensical one rather than this package hiding it. func (c *Client) GetAirportDetails(ctx context.Context, code string, flightLimit, page int) (map[string]any, error) { - if flightLimit <= 0 { + if flightLimit == 0 { flightLimit = defaultFlightLimit } - if page <= 0 { + if page == 0 { page = 1 } if len(code) < 3 || len(code) > 4 { @@ -466,7 +468,7 @@ func (c *Client) GetFlights(ctx context.Context, search FlightSearch) ([]*Flight if err != nil { return nil, err } - flights = flightsFromFeed(content) + flights = flightsFromFeed(content, feedKeyOrder(response.Body)) // "full_count": 0 means the feed really has nothing to report. fullCount := toNumber(content["full_count"]) @@ -489,21 +491,63 @@ func (c *Client) GetFlights(ctx context.Context, search FlightSearch) ([]*Flight } // flightsFromFeed keeps the feed entries that are flights, skipping the -// envelope's own keys. -func flightsFromFeed(content map[string]any) []*Flight { +// envelope's own keys, in the order the feed listed them. +func flightsFromFeed(content map[string]any, order []string) []*Flight { flights := make([]*Flight, 0, len(content)) - for flightID, info := range content { + // Falls back to the map's own order only if the body could not be scanned, + // which cannot happen for a body that already parsed. + if len(order) == 0 { + order = make([]string, 0, len(content)) + + for flightID := range content { + order = append(order, flightID) + } + } + + for _, flightID := range order { if flightID == "" || flightID[0] < '0' || flightID[0] > '9' { continue } - if row, ok := info.([]any); ok { + if row, ok := content[flightID].([]any); ok { flights = append(flights, newFlight(flightID, row)) } } return flights } +// feedKeyOrder reads the keys of a JSON object in the order they arrived. A Go +// map does not keep that order, and both sibling ports hand flights back in the +// order the feed listed them. +func feedKeyOrder(body []byte) []string { + decoder := json.NewDecoder(bytes.NewReader(bytes.TrimPrefix(body, []byte("\xef\xbb\xbf")))) + token, err := decoder.Token() + + if err != nil || token != json.Delim('{') { + return nil + } + + var keys []string + + for decoder.More() { + token, err := decoder.Token() + + if err != nil { + return keys + } + + name, _ := token.(string) + var value json.RawMessage + + // Decoded rather than tokenised, so nested objects are skipped whole. + if err := decoder.Decode(&value); err != nil { + return keys + } + keys = append(keys, name) + } + return keys +} + // fetchDetails fills in every flight's details, MaxWorkers at a time. func (c *Client) fetchDetails(ctx context.Context, flights []*Flight) error { if len(flights) == 0 { @@ -648,9 +692,10 @@ func cloneZone(zone Zone) Zone { } // Search returns the search results, grouped as FR24 counts them. A limit of -// zero means the 50 the Python and Node.js ports default to. +// zero means the 50 the Python and Node.js ports default to; any other value is +// sent as given. func (c *Client) Search(ctx context.Context, query string, limit int) (map[string][]any, error) { - if limit <= 0 { + if limit == 0 { limit = defaultSearchLimit } diff --git a/go/flightradarapi/api_test.go b/go/flightradarapi/api_test.go index 2942879..6afc612 100644 --- a/go/flightradarapi/api_test.go +++ b/go/flightradarapi/api_test.go @@ -1324,3 +1324,82 @@ func TestFlightEndpointsRefuseANilFlight(t *testing.T) { t.Errorf("GetHistoryData: got %v, want an error", err) } } + +func TestGetFlightsKeepsTheOrderTheFeedSent(t *testing.T) { + // A Go map randomises iteration, so identical responses used to come back + // in a different order each call; the sibling ports keep the feed's. + row := []any{"A", 1.0, 2.0, 0.0, 0.0, 0.0, "", 0.0, "a", "r", 0.0, "", "", "", 0.0, 0.0, "", 0.0, ""} + + // Written as text, because a map would not keep the order under test. + ordered := `{"full_count":3,"333":` + rowJSON(t, row) + `,"111":` + rowJSON(t, row) + + `,"222":` + rowJSON(t, row) + `}` + + mux := http.NewServeMux() + mux.HandleFunc("/zones/fcgi/feed.js", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(ordered)) + }) + + client := newTestClient(t, mux) + + for range 5 { + flights, err := client.GetFlights(context.Background(), FlightSearch{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ids := make([]string, 0, len(flights)) + + for _, flight := range flights { + ids = append(ids, flight.ID) + } + if strings.Join(ids, ",") != "333,111,222" { + t.Fatalf("got %v, want the feed's own order", ids) + } + } +} + +func rowJSON(t *testing.T, row []any) string { + t.Helper() + encoded, err := json.Marshal(row) + + if err != nil { + t.Fatal(err) + } + return string(encoded) +} + +func TestOnlyZeroSelectsTheDefaultLimits(t *testing.T) { + // A negative goes to FR24 as given, the way the sibling ports forward it, + // instead of being hidden behind the default. + var query url.Values + mux := http.NewServeMux() + mux.HandleFunc("/common/v1/airport.json", func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + writeJSON(t, w, map[string]any{"result": map[string]any{"response": map[string]any{ + "airport": map[string]any{"pluginData": map[string]any{"details": map[string]any{"name": "x"}}}, + }}}) + }) + mux.HandleFunc("/v1/search/web/find", func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"results":[],"stats":{"count":{}}}`)) + }) + + client := newTestClient(t, mux) + + if _, err := client.GetAirportDetails(context.Background(), "ATL", -5, -2); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if query.Get("limit") != "-5" || query.Get("page") != "-2" { + t.Errorf("got limit=%q page=%q, want them forwarded", query.Get("limit"), query.Get("page")) + } + + if _, err := client.Search(context.Background(), "x", -1); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if query.Get("limit") != "-1" { + t.Errorf("got limit=%q, want it forwarded", query.Get("limit")) + } +} diff --git a/go/flightradarapi/request.go b/go/flightradarapi/request.go index 4eabc47..5a4cd85 100644 --- a/go/flightradarapi/request.go +++ b/go/flightradarapi/request.go @@ -289,7 +289,7 @@ func runWithRetry[T any](ctx context.Context, policy *RetryPolicy, fn func() (T, if attempt < policy.MaxAttempts-1 { select { case <-ctx.Done(): - return zero, ctx.Err() + return zero, fmt.Errorf("%w: %w", ErrFlightRadar, ctx.Err()) case <-time.After(policy.SleepFor(attempt)): } } @@ -414,9 +414,10 @@ type Response struct { Cookies map[string]string } -// IsJSON reports whether the response announced a JSON body. +// IsJSON reports whether the response announced a JSON body. Compared in lower +// case, because a media type is case-insensitive. func (r *Response) IsJSON() bool { - return strings.Contains(r.Header.Get("Content-Type"), "application/json") + return strings.Contains(strings.ToLower(r.Header.Get("Content-Type")), "application/json") } // JSON parses the body as a JSON object. @@ -431,6 +432,12 @@ func (r *Response) JSON() (map[string]any, error) { if err := json.Unmarshal(bytes.TrimPrefix(r.Body, []byte("\xef\xbb\xbf")), &content); err != nil { return nil, fmt.Errorf("%w: could not parse the JSON body of %s: %w", ErrFlightRadar, r.URL, err) } + + // A body of "null" unmarshals into a nil map without complaint, and the + // caller would read every key as missing instead of seeing the failure. + if content == nil { + return nil, fmt.Errorf("%w: %s returned a null JSON body", ErrFlightRadar, r.URL) + } return content, nil } @@ -691,7 +698,11 @@ func responseCookies(headers []string) map[string]string { func decodeBody(content []byte, response *http.Response, target string, limit int) ([]byte, error) { var applied []string - for _, token := range strings.Split(response.Header.Get("Content-Encoding"), ",") { + // Joined first: the header may arrive as several fields, which is the same + // thing on the wire as one comma-separated list. + encodings := strings.Join(response.Header.Values("Content-Encoding"), ",") + + for _, token := range strings.Split(encodings, ",") { token = strings.ToLower(strings.TrimSpace(token)) if token != "" && token != "identity" { @@ -740,7 +751,7 @@ func decodeBody(content []byte, response *http.Response, target string, limit in // logDecodeFailure warns unless the body looks like the transport decoded it // already, in which case there is nothing for a caller to act on. func logDecodeFailure(content []byte, response *http.Response, target, failedAt string, cause error) { - contentType := response.Header.Get("Content-Type") + contentType := strings.ToLower(response.Header.Get("Content-Type")) transportDecoded := false switch { diff --git a/go/flightradarapi/request_test.go b/go/flightradarapi/request_test.go index 12714e6..fd69694 100644 --- a/go/flightradarapi/request_test.go +++ b/go/flightradarapi/request_test.go @@ -1426,3 +1426,84 @@ func TestSleepForRefusesToTurnABadDelayIntoAnEternity(t *testing.T) { } } } + +func TestJSONRefusesANullBody(t *testing.T) { + // "null" unmarshals into a nil map without complaint, and every key would + // then read as missing instead of the caller seeing the failure. + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("null")) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := response.JSON(); !errors.Is(err, ErrFlightRadar) { + t.Errorf("got %v, want a null body refused", err) + } +} + +func TestAMediaTypeIsReadCaseInsensitively(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "Application/JSON; Charset=UTF-8") + w.Write([]byte(`{"a":1}`)) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + content, err := response.JSON() + + if err != nil { + t.Fatalf("got %v, want the body parsed whatever the header's case", err) + } + if content["a"] != 1.0 { + t.Errorf("got %v", content) + } +} + +func TestStackedEncodingsSplitAcrossHeaderFields(t *testing.T) { + // Several Content-Encoding fields mean the same as one comma-separated list. + payload := []byte("stacked across fields") + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Encoding", "gzip") + w.Header().Add("Content-Encoding", "br") + w.Write(brotliBytes(t, gzipBytes(t, payload))) + }) + + response, err := testClient().request(context.Background(), server.URL, requestOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(response.Body, payload) { + t.Errorf("got %q, want %q", response.Body, payload) + } +} + +func TestCancellationDuringBackoffCarriesTheSentinel(t *testing.T) { + server := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(520) + }) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + client := newAPIClient(newHTTPClient(Chrome136Profile()), &RetryPolicy{ + MaxAttempts: 5, BaseDelay: time.Second, + }) + + _, err := client.request(ctx, server.URL, requestOptions{}) + + if !errors.Is(err, ErrFlightRadar) { + t.Errorf("got %v, want it to match ErrFlightRadar", err) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("got %v, want the context cause underneath", err) + } +} From 991570469e09f28af229d66429c6c5310566b5cd Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 23:10:27 -0300 Subject: [PATCH 12/13] fix: make an unset field mean the default, not an empty request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SetFlightTrackerConfig copied the struct it was given without looking at it. Go's zero value is the empty string where the Python dataclass carries a default, so a literal {Limit: "10"} sent the feed adsb=&air=&estimated=&faa=… — while the very same empty value was rejected when it arrived through the values map. Unset fields now take the default and every field is validated, so the two paths agree. RetryPolicy read a zero MaxDelay as "no cap at all", which let a struct literal climb to a four-minute sleep where the Python constructor caps at thirty seconds. Zero now means the default the other ports declare, for BaseDelay as well, so &RetryPolicy{MaxAttempts: 5} backs off exactly like NewRetryPolicy(5). A zero Jitter still means none: a deterministic test wants to be able to ask for that. A cookie with Domain=localhost sent from localhost was discarded by the guard against a bare TLD, though RFC 6265 5.3.5 allows a domain that is the host itself. --- CONTRIBUTING.md | 8 ---- go/flightradarapi/api.go | 7 ++++ go/flightradarapi/api_test.go | 33 ++++++++++++++++ go/flightradarapi/cookies.go | 5 ++- go/flightradarapi/cookies_test.go | 18 +++++++++ go/flightradarapi/flighttrackerconfig.go | 25 ++++++++++++ go/flightradarapi/request.go | 50 ++++++++++++++++++------ go/flightradarapi/request_test.go | 32 +++++++++++---- 8 files changed, 148 insertions(+), 30 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b70dbe3..35709b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,14 +77,6 @@ so the `tag-go-module` job of `publish.yml` pushes `go/v1.6.0` alongside the release tag. Nothing to do by hand; if that job is skipped, `go get .../go@latest` finds no release and callers fall back to a pseudo-version. -## Generated files - -`go/flightradarapi/countries.go` and `zones.go` are generated from -`python/FlightRadarAPI/core.py` and `zones.py`. Change the Python source first, -then regenerate them — `go/flightradarapi/ports_test.go` fails with the -exact entries that drifted, and also checks `FlightTrackerConfig` against the -Python dataclass. - ## Reporting bugs and asking questions - Bugs: open a GitHub issue with the bug report template. diff --git a/go/flightradarapi/api.go b/go/flightradarapi/api.go index 5539b5a..e3d78a9 100644 --- a/go/flightradarapi/api.go +++ b/go/flightradarapi/api.go @@ -581,6 +581,9 @@ func (c *Client) fetchDetails(ctx context.Context, flights []*Flight) error { }() } + // Not guarded by ctx: once it is done, each remaining request fails before + // touching the network — 100 of them cost a millisecond — so draining the + // queue is cheaper than the branch that would stop it. for _, flight := range flights { queue <- flight } @@ -608,10 +611,14 @@ func (c *Client) SetFlightTrackerConfig(config *FlightTrackerConfig, values map[ if config != nil { updated = *config + updated.fillDefaults() } if err := updated.update(values); err != nil { return err } + if err := updated.validate(); err != nil { + return err + } c.flightTrackerConfig = updated return nil } diff --git a/go/flightradarapi/api_test.go b/go/flightradarapi/api_test.go index 6afc612..b5449d8 100644 --- a/go/flightradarapi/api_test.go +++ b/go/flightradarapi/api_test.go @@ -1403,3 +1403,36 @@ func TestOnlyZeroSelectsTheDefaultLimits(t *testing.T) { t.Errorf("got limit=%q, want it forwarded", query.Get("limit")) } } + +func TestSetFlightTrackerConfigFillsAPartialStruct(t *testing.T) { + // Go's zero value is the empty string where the Python dataclass carries a + // default, and the same empty value is rejected through the values map. + client := newTestClient(t, http.NewServeMux()) + + if err := client.SetFlightTrackerConfig(&FlightTrackerConfig{Limit: "10"}, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + config := client.GetFlightTrackerConfig() + + if config.Limit != "10" { + t.Errorf("got limit %q, want the one that was set", config.Limit) + } + if config.MaxAge != "14400" || config.ADSB != "1" { + t.Errorf("got maxage %q adsb %q, want the defaults filled in", config.MaxAge, config.ADSB) + } + if query := config.Values().Encode(); strings.Contains(query, "=&") { + t.Errorf("got %q, want no empty option in the query", query) + } +} + +func TestSetFlightTrackerConfigRejectsABadFieldInTheStruct(t *testing.T) { + client := newTestClient(t, http.NewServeMux()) + + if err := client.SetFlightTrackerConfig(&FlightTrackerConfig{Limit: "dez"}, nil); err == nil { + t.Error("expected a non-numeric field to be reported, as it is through the map") + } + if client.GetFlightTrackerConfig().Limit != "5000" { + t.Error("a rejected config must leave the current one untouched") + } +} diff --git a/go/flightradarapi/cookies.go b/go/flightradarapi/cookies.go index 010debe..8197e65 100644 --- a/go/flightradarapi/cookies.go +++ b/go/flightradarapi/cookies.go @@ -265,8 +265,9 @@ func parseSetCookie(header string, target *url.URL, now time.Time) *storedCookie domain := strings.ToLower(strings.TrimPrefix(value, ".")) // A dotless domain is a TLD: Domain=com would scope the cookie to - // every .com host requested later. - if strings.Contains(domain, ".") && domainMatches(host, domain) { + // every .com host requested later. One that is the host itself is + // not — "localhost" behind a proxy has to keep its session. + if (domain == host || strings.Contains(domain, ".")) && domainMatches(host, domain) { cookie.domain = domain cookie.hostOnly = false } else { diff --git a/go/flightradarapi/cookies_test.go b/go/flightradarapi/cookies_test.go index 1ba48dd..96033b4 100644 --- a/go/flightradarapi/cookies_test.go +++ b/go/flightradarapi/cookies_test.go @@ -275,3 +275,21 @@ func TestJarClampsAnEnormousMaxAge(t *testing.T) { } } } + +func TestJarKeepsACookieScopedToADotlessHost(t *testing.T) { + // Domain=com is a TLD and must be refused; Domain=localhost from localhost + // is the host itself, and dropping it loses the session behind a proxy. + jar := newCookieJar() + jar.store(mustURL(t, "http://localhost/api"), []string{"session=abc; Domain=localhost; Path=/"}) + + if value, ok := jar.get("session"); !ok || value != "abc" { + t.Errorf("got %q, want the cookie kept", value) + } + + jar.clear() + jar.store(mustURL(t, "https://www.flightradar24.com/"), []string{"leaky=1; Domain=com"}) + + if _, ok := jar.get("leaky"); ok { + t.Error("a bare TLD must still be refused") + } +} diff --git a/go/flightradarapi/flighttrackerconfig.go b/go/flightradarapi/flighttrackerconfig.go index 517ac2d..570356a 100644 --- a/go/flightradarapi/flighttrackerconfig.go +++ b/go/flightradarapi/flighttrackerconfig.go @@ -71,6 +71,31 @@ func (c FlightTrackerConfig) Values() url.Values { return values } +// fillDefaults replaces the fields a struct literal left empty. Go's zero value +// is the empty string where the Python dataclass carries a default, so +// FlightTrackerConfig{Limit: "10"} means the same there as here. +func (c *FlightTrackerConfig) fillDefaults() { + defaults := NewFlightTrackerConfig() + fields, defaulted := c.fields(), defaults.fields() + + for name, field := range fields { + if *field == "" { + *field = *defaulted[name] + } + } +} + +// validate refuses a value the feed cannot read, wherever it came from: the +// same empty string is rejected through the values map. +func (c *FlightTrackerConfig) validate() error { + for name, field := range c.fields() { + if !isDecimal(*field) { + return fmt.Errorf("%w: value must be a number, got %q for key %q", ErrFlightRadar, *field, name) + } + } + return nil +} + // update applies name/value pairs, rejecting unknown options and // non-numeric values the way the feed does. func (c *FlightTrackerConfig) update(values map[string]string) error { diff --git a/go/flightradarapi/request.go b/go/flightradarapi/request.go index 5a4cd85..68d9757 100644 --- a/go/flightradarapi/request.go +++ b/go/flightradarapi/request.go @@ -169,16 +169,29 @@ func decompressBrotli(data []byte, limit int) ([]byte, error) { return readBounded(brotli.NewReader(bytes.NewReader(data)), limit, "brotli body") } +// Defaults the Python and Node.js ports declare in their RetryPolicy +// constructors, used here for any field left at zero. +const ( + DefaultRetryBaseDelay = time.Second + DefaultRetryMaxDelay = 30 * time.Second + DefaultRetryJitter = 500 * time.Millisecond +) + // RetryPolicy retries transient failures: a Cloudflare block, a timeout, or a // network error. The zero value retries nothing. type RetryPolicy struct { - // MaxAttempts is the total number of attempts, including the first. + // MaxAttempts is the total number of attempts, including the first. Below + // two, nothing is retried. MaxAttempts int - // BaseDelay is the first backoff sleep. Zero or less means no wait. + // BaseDelay is the first backoff sleep. Zero or less means + // [DefaultRetryBaseDelay], so a struct literal backs off like + // [NewRetryPolicy] rather than hammering. BaseDelay time.Duration - // MaxDelay caps the exponential backoff. Zero means uncapped. + // MaxDelay caps the exponential backoff. Zero or less means + // [DefaultRetryMaxDelay]; for an effectively uncapped policy, set it high. MaxDelay time.Duration - // Jitter is the random span added to each sleep. + // Jitter is the random span added to each sleep. Zero means none, which is + // what a deterministic test wants. Jitter time.Duration } @@ -187,9 +200,9 @@ type RetryPolicy struct { func NewRetryPolicy(maxAttempts int) (*RetryPolicy, error) { policy := &RetryPolicy{ MaxAttempts: maxAttempts, - BaseDelay: time.Second, - MaxDelay: 30 * time.Second, - Jitter: 500 * time.Millisecond, + BaseDelay: DefaultRetryBaseDelay, + MaxDelay: DefaultRetryMaxDelay, + Jitter: DefaultRetryJitter, } if err := policy.validate(); err != nil { return nil, err @@ -213,16 +226,27 @@ func (p *RetryPolicy) validate() error { // MaxDelay caps nothing rather than capping everything at zero, and a negative // Jitter adds nothing rather than panicking. func (p *RetryPolicy) SleepFor(attemptIndex int) time.Duration { - delay := float64(p.BaseDelay) * math.Pow(2, float64(attemptIndex)) + base, capped := p.BaseDelay, p.MaxDelay + + // An unset field means the default, the way it does everywhere else here: + // a literal &RetryPolicy{MaxAttempts: 5} then backs off like the Python + // constructor rather than hammering or climbing past four minutes. + if base <= 0 { + base = DefaultRetryBaseDelay + } + if capped <= 0 { + capped = DefaultRetryMaxDelay + } + + delay := float64(base) * math.Pow(2, float64(attemptIndex)) - // A negative BaseDelay, or the NaN a zero one produces once the doubling - // overflows, would otherwise reach the overflow guard below and come back - // as the longest sleep a Duration can hold. + // NaN once the doubling overflows, which the overflow guard below would + // otherwise read as the longest sleep a Duration can hold. if math.IsNaN(delay) || delay < 0 { delay = 0 } - if capped := float64(p.MaxDelay); capped > 0 && delay > capped { - delay = capped + if delay > float64(capped) { + delay = float64(capped) } if delay >= float64(math.MaxInt64) { return time.Duration(math.MaxInt64) diff --git a/go/flightradarapi/request_test.go b/go/flightradarapi/request_test.go index fd69694..f4a30df 100644 --- a/go/flightradarapi/request_test.go +++ b/go/flightradarapi/request_test.go @@ -1176,16 +1176,34 @@ func TestRetryPolicyValidationReportsNegativeTiming(t *testing.T) { } } -func TestAZeroMaxDelayCapsNothing(t *testing.T) { - // Every field is public, so the zero value has to mean something sane: a - // policy built as a struct literal used to back off for zero seconds. - policy := &RetryPolicy{MaxAttempts: 5, BaseDelay: 2 * time.Second} +func TestAnUnsetFieldMeansTheDocumentedDefault(t *testing.T) { + // Every field is public, so a struct literal is the natural way to build + // one — and it has to behave like NewRetryPolicy, which is what the Python + // and Node.js constructors give: 1s base, 30s cap. + literal := &RetryPolicy{MaxAttempts: 5} + built, err := NewRetryPolicy(5) - for attempt, expected := range map[int]time.Duration{0: 2 * time.Second, 2: 8 * time.Second} { - if got := policy.SleepFor(attempt); got != expected { - t.Errorf("attempt %d: got %v, want %v", attempt, got, expected) + if err != nil { + t.Fatal(err) + } + for _, attempt := range []int{0, 1, 4, 10} { + // Compared without jitter, which the literal deliberately leaves at zero. + built.Jitter = 0 + + if literal.SleepFor(attempt) != built.SleepFor(attempt) { + t.Errorf("attempt %d: got %v, want %v", attempt, literal.SleepFor(attempt), built.SleepFor(attempt)) } } + + // The cap is the 30s the other ports default to, not "no cap at all". + policy := &RetryPolicy{MaxAttempts: 10, BaseDelay: 2 * time.Second} + + if got := policy.SleepFor(0); got != 2*time.Second { + t.Errorf("got %v, want the base delay untouched", got) + } + if got := policy.SleepFor(9); got != DefaultRetryMaxDelay { + t.Errorf("got %v, want it capped at %v", got, DefaultRetryMaxDelay) + } } func TestANegativeJitterAddsNothing(t *testing.T) { From 64de1db8c3833952dcc4853a23e13491243082ad Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 23:29:51 -0300 Subject: [PATCH 13/13] chore: derive the accept-encoding header and pin the CI tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header was written out while its comment claimed it came from the decoder table, so dropping a decoder would have left the client asking for an encoding it can no longer read — and the body would come back compressed, parsed as garbage rather than failing. It is now built from the table, with the order kept apart as the one part that is a choice. staticcheck and govulncheck ran as @latest inside a required CI step, the only tools in these workflows not pinned, so an upstream release could turn the build red on a tree nobody touched. Both are pinned in the Makefile, which the workflow now calls instead of repeating the command. Two comments had drifted from their code: the one on matching still described the one-cookie-per-name behaviour removed in 56c4fdf, and GetAirlineLogo carried an unreachable 5xx branch that read as though a server error fell through to the alternative URL. --- .github/workflows/go-package.yml | 2 +- go/Makefile | 13 ++++++++++--- go/flightradarapi/api.go | 4 +++- go/flightradarapi/cookies.go | 3 ++- go/flightradarapi/request.go | 21 ++++++++++++++++++--- 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.github/workflows/go-package.yml b/.github/workflows/go-package.yml index 4ebb989..447b365 100644 --- a/.github/workflows/go-package.yml +++ b/.github/workflows/go-package.yml @@ -65,7 +65,7 @@ jobs: continue-on-error: ${{ github.event_name == 'push' }} - name: Vulnerability scan (shipped dependencies) if: matrix.go-version == '1.26.x' - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + run: make security - name: Verify the package builds as a dependency run: | go build ./... diff --git a/go/Makefile b/go/Makefile index 98314bb..2d22e3d 100644 --- a/go/Makefile +++ b/go/Makefile @@ -9,6 +9,11 @@ COVERAGE_MIN = 80 GO ?= go +# Pinned, like every action in the workflows: an upstream release adding a check +# would otherwise turn CI red on a tree nobody touched. Bump them deliberately. +STATICCHECK_VERSION = v0.8.1 +GOVULNCHECK_VERSION = v1.7.0 + GREEN = \033[0;32m YELLOW = \033[0;33m NC = \033[0m @@ -96,7 +101,7 @@ lint: .PHONY: lint-strict lint-strict: lint @echo "$(GREEN)Running staticcheck...$(NC)" - $(GO) run honnef.co/go/tools/cmd/staticcheck@latest ./... + $(GO) run honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION) ./... .PHONY: lint-fix lint-fix: @@ -108,11 +113,13 @@ tidy: @echo "$(GREEN)Tidying modules...$(NC)" $(GO) mod tidy -# Uses govulncheck, the same tool the CI workflow runs. +# Uses govulncheck, the same tool the CI workflow runs. A finding in the +# standard library means the local toolchain is behind, not that this package +# is: CI resolves 1.26.x to the newest patch and sees none. .PHONY: security security: @echo "$(GREEN)Running vulnerability scan (govulncheck)...$(NC)" - $(GO) run golang.org/x/vuln/cmd/govulncheck@latest ./... + $(GO) run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./... .PHONY: docs docs: diff --git a/go/flightradarapi/api.go b/go/flightradarapi/api.go index e3d78a9..19e1d3d 100644 --- a/go/flightradarapi/api.go +++ b/go/flightradarapi/api.go @@ -164,7 +164,9 @@ func (c *Client) GetAirlineLogo(ctx context.Context, iata, icao string) (*Image, if err != nil { return nil, err } - if response.StatusCode < 400 || response.StatusCode >= 500 { + // Only 403 and 404 reach here as failures; any other non-2xx already + // came back as an error, so there is nothing else to fall through for. + if response.StatusCode < 400 { return &Image{Data: response.Body, Extension: extensionOf(logoURL)}, nil } } diff --git a/go/flightradarapi/cookies.go b/go/flightradarapi/cookies.go index 8197e65..db12b84 100644 --- a/go/flightradarapi/cookies.go +++ b/go/flightradarapi/cookies.go @@ -142,7 +142,8 @@ func (j *cookieJar) header(target *url.URL) string { return strings.Join(pairs, "; ") } -// matching returns the in-scope cookies, oldest first, one per name. +// matching returns every in-scope cookie, ordered as RFC 6265 5.4 asks: the +// longest path first, and the oldest first among equal paths. func (j *cookieJar) matching(target *url.URL) []*storedCookie { if target == nil { return nil diff --git a/go/flightradarapi/request.go b/go/flightradarapi/request.go index 68d9757..6ffb20b 100644 --- a/go/flightradarapi/request.go +++ b/go/flightradarapi/request.go @@ -43,9 +43,24 @@ var decoders = map[string]func(data []byte, limit int) ([]byte, error){ "br": decompressBrotli, } -// supportedEncodings is advertised on every request. Derived from the decoder -// table, so advertising an encoding with no decoder is not expressible. -const supportedEncodings = "gzip, deflate, br" +// encodingOrder is the order Chrome advertises them in. Kept apart from the +// table below so the header's order is a choice and its contents are not. +var encodingOrder = []string{"gzip", "deflate", "br"} + +// supportedEncodings is advertised on every request, and is built from the +// decoder table rather than written out: advertising an encoding this package +// cannot decode is then not expressible. The reverse — a decoder that never +// reaches the header — is what TestEveryAdvertisedEncodingHasADecoder catches. +var supportedEncodings = func() string { + names := make([]string, 0, len(decoders)) + + for _, name := range encodingOrder { + if _, ok := decoders[name]; ok { + names = append(names, name) + } + } + return strings.Join(names, ", ") +}() var logger atomic.Pointer[slog.Logger]