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/.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..447b365
--- /dev/null
+++ b/.github/workflows/go-package.yml
@@ -0,0 +1,91 @@
+name: Go Package
+
+on:
+ push:
+ branches:
+ - 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 * *'
+ 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: make security
+ - 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'
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/CONTRIBUTING.md b/CONTRIBUTING.md
index 63c6d46..35709b6 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
+```
-When you change behavior, change it in both SDKs in the same PR unless there
+## Keeping the SDKs aligned
+
+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.
+ `docs/go.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,17 @@ 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.
## Reporting bugs and asking questions
diff --git a/README.md b/README.md
index 887612c..2bab231 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,20 @@
# 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/
[](https://github.com/JeanExtreme002/FlightRadarAPI/actions)
-[](https://pypi.org/project/FlightRadarAPI/)
+[](https://github.com/JeanExtreme002/FlightRadarAPI/actions)
+[](https://github.com/JeanExtreme002/FlightRadarAPI/actions)
[](https://github.com/JeanExtreme002/FlightRadarAPI)
+[](https://pypi.org/project/FlightRadarAPI/)
[](https://pypi.org/project/FlightRadarAPI/)
[](https://www.npmjs.com/package/flightradarapi)
+[](https://www.npmjs.com/package/flightradarapi)
+[](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi)
+[](https://go.dev/dl/)
[](https://pypi.org/project/FlightRadarAPI/)
[](https://pypi.org/project/FlightRadarAPI/)
@@ -24,8 +29,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/go.md b/docs/go.md
new file mode 100644
index 0000000..6997bfa
--- /dev/null
+++ b/docs/go.md
@@ -0,0 +1,280 @@
+---
+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 (
+ "time"
+
+ "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",
+})
+```
+
+### 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
+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})
+```
+
+### 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
+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/docs/index.md b/docs/index.md
index 6b04dfa..db36a63 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,16 +1,21 @@
# 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).
See more information at [Flightradar24's terms and conditions](https://www.flightradar24.com/terms-and-conditions).
[](https://github.com/JeanExtreme002/FlightRadarAPI/actions)
-[](https://pypi.org/project/FlightRadarAPI/)
+[](https://github.com/JeanExtreme002/FlightRadarAPI/actions)
+[](https://github.com/JeanExtreme002/FlightRadarAPI/actions)
[](https://github.com/JeanExtreme002/FlightRadarAPI)
+[](https://pypi.org/project/FlightRadarAPI/)
[](https://pypi.org/project/FlightRadarAPI/)
[](https://www.npmjs.com/package/flightradarapi)
+[](https://www.npmjs.com/package/flightradarapi)
+[](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi)
+[](https://go.dev/dl/)
[](https://pypi.org/project/FlightRadarAPI/)
[](https://pypi.org/project/FlightRadarAPI/)
@@ -26,11 +31,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 +62,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/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..2d22e3d
--- /dev/null
+++ b/go/Makefile
@@ -0,0 +1,149 @@
+# 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
+
+# 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
+
+.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@$(STATICCHECK_VERSION) ./...
+
+.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. 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@$(GOVULNCHECK_VERSION) ./...
+
+.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..17cabf9
--- /dev/null
+++ b/go/README.md
@@ -0,0 +1,72 @@
+# 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/
+
+[](https://github.com/JeanExtreme002/FlightRadarAPI/actions)
+[](https://github.com/JeanExtreme002/FlightRadarAPI)
+[](https://pypi.org/project/FlightRadarAPI/)
+[](https://pypi.org/project/FlightRadarAPI/)
+[](https://www.npmjs.com/package/flightradarapi)
+[](https://www.npmjs.com/package/flightradarapi)
+[](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi)
+[](https://go.dev/dl/)
+[](https://pypi.org/project/FlightRadarAPI/)
+[](https://pypi.org/project/FlightRadarAPI/)
+
+## Installing FlightRadarAPI:
+```
+$ go get github.com/JeanExtreme002/FlightRadarAPI/go
+```
+
+## 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
+import "github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi"
+
+client := flightradarapi.New()
+```
+
+**Getting flights list:**
+```go
+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,
+})
+
+// Pass nil to get every airport
+allAirports, err := client.GetAirports(ctx, nil)
+```
+
+**Getting airlines list:**
+```go
+airlines, err := client.GetAirlines(ctx) // Returns detailed airline information with IATA/ICAO codes
+```
+
+**Getting zones list:**
+```go
+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
+
+// AllCountries() enumerates them all
+for _, country := range flightradarapi.AllCountries() { }
+```
+
+## Documentation
+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).
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..19e1d3d
--- /dev/null
+++ b/go/flightradarapi/api.go
@@ -0,0 +1,911 @@
+package flightradarapi
+
+import (
+ "bytes"
+ "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
+ }
+ // 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
+ }
+ }
+ 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. 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 {
+ 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) {
+ 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,
+ })
+
+ 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, feedKeyOrder(response.Body))
+
+ // "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, in the order the feed listed them.
+func flightsFromFeed(content map[string]any, order []string) []*Flight {
+ flights := make([]*Flight, 0, len(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 := 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 {
+ 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)
+ }
+ }()
+ }
+
+ // 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
+ }
+ 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
+ updated.fillDefaults()
+ }
+ if err := updated.update(values); err != nil {
+ return err
+ }
+ if err := updated.validate(); 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) {
+ if flight == nil {
+ return "", fmt.Errorf("%w: no flight given", ErrFlightRadar)
+ }
+
+ 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; 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 {
+ 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..b5449d8
--- /dev/null
+++ b/go/flightradarapi/api_test.go
@@ -0,0 +1,1438 @@
+package flightradarapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "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")
+ }
+}
+
+// --- 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)
+ }
+
+ corners := make([]float64, len(parts))
+
+ 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)
+ }
+ 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)
+ }
+}
+
+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)
+ }
+}
+
+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)
+ }
+}
+
+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"))
+ }
+}
+
+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
new file mode 100644
index 0000000..db12b84
--- /dev/null
+++ b/go/flightradarapi/cookies.go
@@ -0,0 +1,344 @@
+package flightradarapi
+
+import (
+ "cmp"
+ "math"
+ "net/http"
+ "net/url"
+ "slices"
+ "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 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
+ }
+
+ 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)
+ }
+ }
+
+ // 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
+ }
+ return cmp.Compare(a.storedAt, b.storedAt)
+ })
+
+ return matches
+}
+
+// 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. 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 {
+ // 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..96033b4
--- /dev/null
+++ b/go/flightradarapi/cookies_test.go
@@ -0,0 +1,295 @@
+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 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=/"})
+
+ if value, _ := jar.get("token"); value != "new" {
+ t.Errorf("got %q, want new", value)
+ }
+}
+
+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)
+ }
+}
+
+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)
+ }
+ }
+}
+
+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/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..9acaa16
--- /dev/null
+++ b/go/flightradarapi/entities_test.go
@@ -0,0 +1,646 @@
+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)
+ }
+}
+
+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)
+ }
+}
+
+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/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..94891a3
--- /dev/null
+++ b/go/flightradarapi/flight.go
@@ -0,0 +1,516 @@
+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()
+
+ 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]
+ }
+ 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 check.prefix == "" {
+ if !equalValues(check.wanted, actual) {
+ return false, nil
+ }
+ continue
+ }
+
+ wantedNumber, actualNumber := toNumber(check.wanted), toNumber(actual)
+
+ if actualNumber == nil {
+ return false, nil
+ }
+ if check.prefix == "min" && *actualNumber < *wantedNumber {
+ return false, nil
+ }
+ if check.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")
+
+ // 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")
+
+ f.Details = &FlightDetails{
+ AircraftAge: getString(aircraft, "age"),
+ AircraftCountryID: getNumber(aircraft, "countryId"),
+ AircraftHistory: getSlice(history, "aircraft"),
+ AircraftImages: 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..570356a
--- /dev/null
+++ b/go/flightradarapi/flighttrackerconfig.go
@@ -0,0 +1,129 @@
+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
+}
+
+// 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 {
+ 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..57fd6b8
--- /dev/null
+++ b/go/flightradarapi/parsers.go
@@ -0,0 +1,368 @@
+package flightradarapi
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "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
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) +} + +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)) + } +} + +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/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..6ffb20b --- /dev/null +++ b/go/flightradarapi/request.go @@ -0,0 +1,815 @@ +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, +} + +// 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] + +// 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") +} + +// 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. Below + // two, nothing is retried. + MaxAttempts int + // 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 or less means + // [DefaultRetryMaxDelay]; for an effectively uncapped policy, set it high. + MaxDelay time.Duration + // Jitter is the random span added to each sleep. Zero means none, which is + // what a deterministic test wants. + 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: DefaultRetryBaseDelay, + MaxDelay: DefaultRetryMaxDelay, + Jitter: DefaultRetryJitter, + } + 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 { + 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)) + + // 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 delay > float64(capped) { + delay = float64(capped) + } + if delay >= float64(math.MaxInt64) { + return time.Duration(math.MaxInt64) + } + + jitter := int64(0) + + if p.Jitter > 0 { + 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(total) +} + +// 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, fmt.Errorf("%w: %w", ErrFlightRadar, 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 { + // 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. + 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. Compared in lower +// case, because a media type is case-insensitive. +func (r *Response) IsJSON() bool { + return strings.Contains(strings.ToLower(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) + } + + // 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 +} + +// 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 + + // 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 { + 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 { + // 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() + + 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 { + // 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", + 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 + + // 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" { + 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 := strings.ToLower(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..f4a30df --- /dev/null +++ b/go/flightradarapi/request_test.go @@ -0,0 +1,1527 @@ +package flightradarapi + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "compress/zlib" + "context" + "errors" + "fmt" + "io" + "log/slog" + "math" + "net" + "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 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) + + 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) { + 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) + } +} + +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") + } +} + +// 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) + } +} + +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) + } + } + } +} + +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) + } +} 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 + | +x | +x | +LA / LAN | +340 aircraft | +
| + Gol + | +x | +x | +G3 / GLO | +140 aircraft | +
| + Delta Air Lines + | +x | +x | +DL / DAL | +900 aircraft | +
| + Sky2 + | +x | +x | +SK | +10 aircraft | +
| + SkyTeam + | +x | +x | +SKT | +15 aircraft | +
| placeholder | +||||
| + Should Skip + | +