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/ [![Python Package](https://github.com/JeanExtreme002/FlightRadarAPI/workflows/Python%20Package/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) -[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) +[![Node.js Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/node-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) +[![Go Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/go-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi) +[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) [![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](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). [![Python Package](https://github.com/JeanExtreme002/FlightRadarAPI/workflows/Python%20Package/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) -[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) +[![Node.js Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/node-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) +[![Go Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/go-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi) +[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) [![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](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/ + +[![Go Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/go-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) +[![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) +[![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) +[![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi) +[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) +[![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) +[![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](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 in response — FR24 page layout may have changed") + return []Airline{} + } + + airlines := []Airline{} + + for _, row := range findElements(tbody, func(node *html.Node) bool { return node.Data == "tr" }) { + cells := findElements(row, func(node *html.Node) bool { return node.Data == "td" }) + notranslate := findElement(row, func(node *html.Node) bool { + return node.Data == "td" && hasClass(node, "notranslate") + }) + + if notranslate == nil { + continue + } + + link := findElement(notranslate, func(node *html.Node) bool { + return node.Data == "a" && strings.HasPrefix(attr(node, "href"), "/data/airlines") + }) + + if link == nil { + continue + } + + name := textContent(link) + + if len(name) < 2 { + continue + } + + airline := Airline{Name: name} + + if len(cells) >= 4 { + codes := textContent(cells[3]) + + switch { + case strings.Contains(codes, " / "): + if parts := strings.Split(codes, " / "); len(parts) == 2 { + airline.IATA = strings.TrimSpace(parts[0]) + airline.ICAO = strings.TrimSpace(parts[1]) + } + case len(codes) == 2: + airline.IATA = codes + case len(codes) == 3: + airline.ICAO = codes + } + } + + if len(cells) >= 5 { + if text := textContent(cells[4]); text != "" { + field := strings.TrimSpace(strings.SplitN(text, " ", 2)[0]) + + if count, err := strconv.Atoi(field); err == nil { + airline.NumAircrafts = &count + } + } + } + airlines = append(airlines, airline) + } + return airlines +} + +// countryToSlug slugifies a country name the way FR24 spells it in its data page +// URLs, so feed rows can be matched against the Country constants. +func countryToSlug(country string) string { + // Diacritics are stripped so a future "Curaçao" still matches "curacao". + decomposed := norm.NFKD.String(country) + var ascii strings.Builder + + for _, char := range decomposed { + if !unicode.Is(unicode.Mn, char) { + ascii.WriteRune(char) + } + } + + var slug strings.Builder + previousHyphen := false + + // Punctuation becomes a hyphen rather than being deleted: FR24's own assets + // are named that way, e.g. flags-small/cote-d-ivoire.svg. + for _, char := range strings.ToLower(ascii.String()) { + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') { + slug.WriteRune(char) + previousHyphen = false + continue + } + if !previousHyphen { + slug.WriteRune('-') + previousHyphen = true + } + } + return strings.Trim(slug.String(), "-") +} + +// toText keeps a text field as a string, or "" when the feed sends anything +// else: get_country_flag(airport.Country) would otherwise slugify a map. +func toText(value any) string { + if text, ok := value.(string); ok { + return text + } + return "" +} + +// toNumber coerces a numeric feed field into a number, or nil when it is +// unusable. An unusable coordinate must not become 0: that would place the +// airport in the Gulf of Guinea instead of marking its position as unknown. +func toNumber(value any) *float64 { + switch typed := value.(type) { + case json.Number: + return parseNumber(string(typed)) + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) { + return nil + } + return &typed + case string: + trimmed := strings.Trim(typed, surroundingSpace) + + if !numericPattern.MatchString(trimmed) { + return nil + } + return parseNumber(trimmed) + default: + return nativeNumber(value) + } +} + +// nativeNumber reads Go's own numeric types, which the JSON decoder never +// produces but a caller building a map by hand does. Booleans stay out: the +// other ports reject them too. +func nativeNumber(value any) *float64 { + reflected := reflect.ValueOf(value) + var number float64 + + switch reflected.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + number = float64(reflected.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + number = float64(reflected.Uint()) + case reflect.Float32, reflect.Float64: + number = reflected.Float() + default: + return nil + } + + if math.IsNaN(number) || math.IsInf(number, 0) { + return nil + } + return &number +} + +// parseNumber converts numeric text, rejecting anything a float64 cannot hold. +func parseNumber(text string) *float64 { + number, err := strconv.ParseFloat(text, 64) + + if err != nil { + // Underflow keeps its value (Python's float("1e-999") is 0.0); overflow + // has none. + if !errors.Is(err, strconv.ErrRange) || math.IsInf(number, 0) { + return nil + } + } + if math.IsNaN(number) || math.IsInf(number, 0) { + return nil + } + return &number +} + +// parseAirportsJSON parses the airports JSON feed into airports, keeping only +// the wanted countries when any are given. +func parseAirportsJSON(payload []byte, countries []Country) []*Airport { + decoder := json.NewDecoder(bytes.NewReader(bytes.TrimPrefix(payload, []byte("\xef\xbb\xbf")))) + + // Numbers stay as text, so a literal no float64 can hold is rejected + // instead of silently becoming an infinity. + decoder.UseNumber() + + var data map[string]any + + if err := decoder.Decode(&data); err != nil { + log().Warn("parseAirportsJSON: response is not valid JSON — FR24 feed may have changed") + return []*Airport{} + } + + // Decode stops at the first value, so a truncated or spliced body would + // otherwise pass as valid. The Python and Node.js parsers reject it. + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + log().Warn("parseAirportsJSON: response carries data past the JSON body — FR24 feed may have changed") + return []*Airport{} + } + + rows, ok := data["rows"].([]any) + + if !ok { + log().Warn(`parseAirportsJSON: no "rows" array in response — FR24 feed may have changed`) + return []*Airport{} + } + + var wanted map[string]bool + + if countries != nil { + wanted = make(map[string]bool, len(countries)) + + for _, country := range countries { + wanted[countryToSlug(string(country))] = true + } + } + + matched := make(map[string]bool, len(wanted)) + var unpositioned []string + airports := []*Airport{} + + for _, entry := range rows { + row, ok := entry.(map[string]any) + + if !ok { + continue + } + + // Slugified only when filtering: this loop runs over every airport in + // the feed. + if wanted != nil { + slug := countryToSlug(toText(row["country"])) + + if !wanted[slug] { + continue + } + matched[slug] = true + } + + latitude := toNumber(row["lat"]) + longitude := toNumber(row["lon"]) + + // One bad coordinate drops both: half a position reads as located. + if latitude == nil || longitude == nil { + latitude, longitude = nil, nil + unpositioned = append(unpositioned, toText(row["name"])) + } + + airports = append(airports, newAirportFromBasicInfo(basicAirportInfo{ + Name: toText(row["name"]), + ICAO: toText(row["icao"]), + IATA: toText(row["iata"]), + Latitude: latitude, + Longitude: longitude, + Altitude: toNumber(row["alt"]), + Country: toText(row["country"]), + })) + } + + // One line, not one per row: the feed carries every airport. + if len(unpositioned) > 0 { + log().Warn("parseAirportsJSON: airports with unusable coordinates carry no position", + "count", len(unpositioned), "examples", unpositioned[:min(3, len(unpositioned))]) + } + + if wanted != nil { + var missing []string + + for slug := range wanted { + if !matched[slug] { + missing = append(missing, slug) + } + } + if len(missing) > 0 { + slices.Sort(missing) + log().Warn("parseAirportsJSON: no airports found for some countries — check the Country constants", + "countries", strings.Join(missing, ", ")) + } + } + return airports +} + +// --- HTML helpers --- + +func findElement(root *html.Node, match func(*html.Node) bool) *html.Node { + for node := range root.Descendants() { + if node.Type == html.ElementNode && match(node) { + return node + } + } + return nil +} + +func findElements(root *html.Node, match func(*html.Node) bool) []*html.Node { + var found []*html.Node + + for node := range root.Descendants() { + if node.Type == html.ElementNode && match(node) { + found = append(found, node) + } + } + return found +} + +func attr(node *html.Node, name string) string { + for _, attribute := range node.Attr { + if attribute.Key == name { + return attribute.Val + } + } + return "" +} + +func hasClass(node *html.Node, name string) bool { + return slices.Contains(strings.Fields(attr(node, "class")), name) +} + +// textContent concatenates the node's text, trimming each piece, as +// BeautifulSoup's get_text(strip=True) does. +func textContent(root *html.Node) string { + var text strings.Builder + + for node := range root.Descendants() { + if node.Type == html.TextNode { + text.WriteString(strings.TrimSpace(node.Data)) + } + } + return text.String() +} diff --git a/go/flightradarapi/parsers_test.go b/go/flightradarapi/parsers_test.go new file mode 100644 index 0000000..2bd0718 --- /dev/null +++ b/go/flightradarapi/parsers_test.go @@ -0,0 +1,489 @@ +package flightradarapi + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "strings" + "testing" +) + +// Offline parser tests against the bundled fixtures, mirroring +// python/tests/test_parsers_offline.py and nodejs/tests/testParsersOffline.js. +// When FR24 changes a page or feed, update the fixtures: the assertions here +// guard the parser's invariants, not byte-for-byte equality with production. + +func loadFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) + + if err != nil { + t.Fatalf("could not read fixture %s: %v", name, err) + } + return data +} + +func airlinesByName(t *testing.T) map[string]Airline { + t.Helper() + byName := map[string]Airline{} + + for _, airline := range parseAirlinesHTML(loadFixture(t, "airlines.html")) { + byName[airline.Name] = airline + } + return byName +} + +func airportsByIATA(t *testing.T, airports []*Airport) map[string]*Airport { + t.Helper() + byIATA := map[string]*Airport{} + + for _, airport := range airports { + byIATA[airport.IATA] = airport + } + return byIATA +} + +// airportsFromRow parses a one-row feed built from a raw JSON literal, the way +// both other ports do it. +func airportsFromRow(t *testing.T, row string) []*Airport { + t.Helper() + airports := parseAirportsJSON([]byte(`{"rows":[`+row+`]}`), nil) + + if len(airports) != 1 { + t.Fatalf("got %d airports for row %s, want 1", len(airports), row) + } + return airports +} + +// --- parseAirlinesHTML --- + +func TestParseAirlinesHTMLExtractsKnownRows(t *testing.T) { + byName := airlinesByName(t) + + for _, name := range []string{"LATAM Airlines", "Gol", "Delta Air Lines"} { + if _, ok := byName[name]; !ok { + t.Errorf("missing airline %q", name) + } + } +} + +func TestParseAirlinesHTMLSplitsIATAAndICAO(t *testing.T) { + byName := airlinesByName(t) + + for _, expected := range []struct{ name, iata, icao string }{ + {"LATAM Airlines", "LA", "LAN"}, + {"Gol", "G3", "GLO"}, + } { + airline := byName[expected.name] + + if airline.IATA != expected.iata || airline.ICAO != expected.icao { + t.Errorf("%s: got IATA %q / ICAO %q, want %q / %q", + expected.name, airline.IATA, airline.ICAO, expected.iata, expected.icao) + } + } +} + +func TestParseAirlinesHTMLHandlesIATAOrICAOOnly(t *testing.T) { + byName := airlinesByName(t) + + if byName["Sky2"].IATA != "SK" || byName["Sky2"].ICAO != "" { + t.Errorf("Sky2: got IATA %q / ICAO %q", byName["Sky2"].IATA, byName["Sky2"].ICAO) + } + if byName["SkyTeam"].ICAO != "SKT" || byName["SkyTeam"].IATA != "" { + t.Errorf("SkyTeam: got IATA %q / ICAO %q", byName["SkyTeam"].IATA, byName["SkyTeam"].ICAO) + } +} + +func TestParseAirlinesHTMLParsesAircraftCount(t *testing.T) { + byName := airlinesByName(t) + + for _, expected := range []struct { + name string + count int + }{{"LATAM Airlines", 340}, {"Gol", 140}} { + count := byName[expected.name].NumAircrafts + + if count == nil || *count != expected.count { + t.Errorf("%s: got %v aircraft, want %d", expected.name, count, expected.count) + } + } +} + +func TestParseAirlinesHTMLSkipsInvalidRows(t *testing.T) { + airlines := parseAirlinesHTML(loadFixture(t, "airlines.html")) + + // 5 valid rows; 2 invalid (no notranslate, wrong href) must be skipped. + if len(airlines) != 5 { + t.Errorf("got %d airlines, want 5", len(airlines)) + } +} + +func TestParseAirlinesHTMLEmptyInputReturnsEmptyList(t *testing.T) { + for _, page := range []string{"", "

no tbody here

"} { + if airlines := parseAirlinesHTML([]byte(page)); len(airlines) != 0 { + t.Errorf("got %d airlines for %q, want 0", len(airlines), page) + } + } +} + +// --- parseAirportsJSON --- + +func TestParseAirportsJSONExtractsBasicFields(t *testing.T) { + byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil)) + + for _, code := range []string{"GRU", "GIG"} { + if _, ok := byIATA[code]; !ok { + t.Fatalf("missing airport %q", code) + } + } + + gru := byIATA["GRU"] + + if gru.ICAO != "SBGR" || gru.Country != "Brazil" { + t.Errorf("GRU: got ICAO %q country %q", gru.ICAO, gru.Country) + } + if gru.Latitude == nil || math.Abs(*gru.Latitude-(-23.429991)) > 1e-6 { + t.Errorf("GRU latitude: got %v", gru.Latitude) + } + if gru.Longitude == nil || math.Abs(*gru.Longitude-(-46.4674)) > 1e-6 { + t.Errorf("GRU longitude: got %v", gru.Longitude) + } + if gru.Altitude == nil || *gru.Altitude != 2436 { + t.Errorf("GRU altitude: got %v, want 2436", gru.Altitude) + } +} + +func TestParseAirportsJSONFormatsAltitudeWithoutDecimals(t *testing.T) { + // Both other ports report 2436, never 2436.0. + byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil)) + + if got := formatNumber(*byIATA["GRU"].Altitude); got != "2436" { + t.Errorf("got altitude %q, want \"2436\"", got) + } +} + +func TestParseAirportsJSONKeepsEveryCountryWithoutFilter(t *testing.T) { + countries := map[string]bool{} + + for _, airport := range parseAirportsJSON(loadFixture(t, "airports.json"), nil) { + countries[airport.Country] = true + } + + for _, country := range []string{"Brazil", "United States", "Spain"} { + if !countries[country] { + t.Errorf("missing country %q", country) + } + } +} + +func TestParseAirportsJSONFiltersByCountrySlug(t *testing.T) { + airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{"united-states"}) + + if len(airports) == 0 { + t.Fatal("no airports returned") + } + for _, airport := range airports { + if airport.Country != "United States" { + t.Errorf("got country %q, want United States", airport.Country) + } + } +} + +func TestParseAirportsJSONAcceptsSeveralCountries(t *testing.T) { + airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{"brazil", "spain"}) + countries := map[string]bool{} + + for _, airport := range airports { + countries[airport.Country] = true + } + if len(countries) != 2 || !countries["Brazil"] || !countries["Spain"] { + t.Errorf("got countries %v, want Brazil and Spain", countries) + } +} + +func TestParseAirportsJSONInvalidCoordinatesBecomeNil(t *testing.T) { + // Regression: invalid coords used to be coerced to (0, 0), placing the + // airport in the Gulf of Guinea. + byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil)) + bad := byIATA["BAD"] + + if bad == nil { + t.Fatal("missing airport BAD") + } + if bad.Latitude != nil || bad.Longitude != nil { + t.Errorf("got position (%v, %v), want no position", bad.Latitude, bad.Longitude) + } +} + +func TestParseAirportsJSONRejectsNumericLookingJunk(t *testing.T) { + payload, err := json.Marshal(map[string]any{"rows": []any{map[string]any{ + "name": "Junk Airport", "iata": "JNK", "icao": "JJNK", + "lat": "43.30 N", "lon": -8.37725, "country": "Spain", "alt": "-1", + }}}) + + if err != nil { + t.Fatal(err) + } + + airport := parseAirportsJSON(payload, nil)[0] + + // One bad coordinate drops both; altitude is independent and survives. + if airport.Latitude != nil || airport.Longitude != nil { + t.Errorf("got position (%v, %v), want no position", airport.Latitude, airport.Longitude) + } + if airport.Altitude == nil || *airport.Altitude != -1 { + t.Errorf("got altitude %v, want -1", airport.Altitude) + } +} + +func TestParseAirportsJSONAgreesWithOtherPortsOnUnicode(t *testing.T) { + cases := []struct { + name string + value string + expected *float64 + }{ + // Arabic-Indic digits, which a Unicode-aware \d matched before. + {"arabic-indic digits", "٤٣", nil}, + // U+FEFF, which JavaScript's trim() drops and Python's strip() keeps. + {"leading byte-order mark", "\ufeff43", nil}, + // U+001C, which Python's strip() drops and JavaScript's trim() keeps. + {"leading file separator", "\u001c43", nil}, + {"plain spaces are trimmed", " 43 ", float64Ptr(43)}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + row, err := json.Marshal(map[string]any{ + "name": "X", "iata": "XXX", "icao": "XXXX", "country": "Spain", + "lat": testCase.value, "lon": testCase.value, "alt": testCase.value, + }) + + if err != nil { + t.Fatal(err) + } + + airport := airportsFromRow(t, string(row))[0] + + if !equalOptional(airport.Altitude, testCase.expected) { + t.Errorf("got altitude %v, want %v", airport.Altitude, testCase.expected) + } + }) + } +} + +func TestParseAirportsJSONNumericCoercion(t *testing.T) { + // Keep in step with the same list in the Python and Node.js suites. + cases := []struct { + name string + literal string + expected *float64 + }{ + {"whitespace", `" "`, nil}, + {"an array holding a number", `[43]`, nil}, + {"a coordinate with a hemisphere suffix", `"43.30 N"`, nil}, + {"an exponent overflowing a double", `"1e999"`, nil}, + {"400 plain digits", `"` + repeat("1", 400) + `"`, nil}, + {"a whole number as a string", `"2436"`, float64Ptr(2436)}, + {"a negative decimal", `-23.4`, float64Ptr(-23.4)}, + {"a genuine zero", `0`, float64Ptr(0)}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + row := fmt.Sprintf( + `{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain","lat":%s,"lon":%s,"alt":%s}`, + testCase.literal, testCase.literal, testCase.literal) + airport := airportsFromRow(t, row)[0] + + for label, got := range map[string]*float64{ + "latitude": airport.Latitude, + "longitude": airport.Longitude, + "altitude": airport.Altitude, + } { + if !equalOptional(got, testCase.expected) { + t.Errorf("%s: got %v, want %v", label, got, testCase.expected) + } + } + }) + } +} + +func TestParseAirportsJSONSurvivesRawNumbersNoDoubleCanHold(t *testing.T) { + for _, literal := range []string{repeat("1", 400), "9007199254740993", "-" + repeat("1", 400)} { + row := fmt.Sprintf( + `{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain","lat":%s,"lon":1,"alt":2}`, literal) + latitude := airportsFromRow(t, row)[0].Latitude + + if latitude != nil && (math.IsInf(*latitude, 0) || math.IsNaN(*latitude)) { + t.Errorf("%s: got %v, want nil or a finite number", literal, *latitude) + } + } +} + +func TestParseAirportsJSONKeepsTextFieldsAsStrings(t *testing.T) { + // Anything else breaks GetCountryFlag(airport.Country). + for _, literal := range []string{"null", "0", "false", "true", "[]", "{}", "123"} { + row := fmt.Sprintf( + `{"name":%s,"iata":%s,"icao":%s,"country":%s,"lat":1,"lon":2,"alt":3}`, + literal, literal, literal, literal) + airport := airportsFromRow(t, row)[0] + + if airport.Name != "" || airport.IATA != "" || airport.ICAO != "" || airport.Country != "" { + t.Errorf("%s: got %q/%q/%q/%q, want empty strings", + literal, airport.Name, airport.IATA, airport.ICAO, airport.Country) + } + } +} + +func TestParseAirportsJSONCountrySpellingAndSlugRoundTrip(t *testing.T) { + byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil)) + ann := byIATA["VBA"] + + if ann == nil { + t.Fatal("missing airport VBA") + } + if ann.Country != "Myanmar (Burma)" { + t.Errorf("got country %q", ann.Country) + } + if slug := countryToSlug(ann.Country); slug != "myanmar-burma" { + t.Errorf("got slug %q, want myanmar-burma", slug) + } + if ann.Altitude == nil || *ann.Altitude != 43 { + t.Errorf("got altitude %v, want 43", ann.Altitude) + } +} + +func TestParseAirportsJSONFilterAcceptsEitherCountrySpelling(t *testing.T) { + for _, spelling := range []Country{"myanmar-burma", "Myanmar (Burma)", CountryMyanmarBurma} { + airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{spelling}) + + if len(airports) != 1 { + t.Errorf("%q: got %d airports, want 1", spelling, len(airports)) + } + } +} + +func TestParseAirportsJSONSkipsRowsThatAreNotObjects(t *testing.T) { + payload := []byte(`{"rows":[[1,2,3],"x",7,null,` + + `{"name":"Real","iata":"RRR","icao":"RRRR","country":"Spain","lat":1,"lon":2,"alt":3}]}`) + airports := parseAirportsJSON(payload, nil) + + if len(airports) != 1 || airports[0].IATA != "RRR" { + t.Errorf("got %d airports, want only RRR", len(airports)) + } +} + +func TestParseAirportsJSONUnknownCountryReturnsEmptyList(t *testing.T) { + if airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{"atlantis"}); len(airports) != 0 { + t.Errorf("got %d airports, want 0", len(airports)) + } +} + +func TestParseAirportsJSONInvalidPayloadReturnsEmptyList(t *testing.T) { + for _, payload := range []string{"", "{}", "not json"} { + if airports := parseAirportsJSON([]byte(payload), nil); len(airports) != 0 { + t.Errorf("%q: got %d airports, want 0", payload, len(airports)) + } + } +} + +// --- countryToSlug --- + +func TestCountryToSlugMatchesCountryConstants(t *testing.T) { + cases := map[string]string{ + "United States": "united-states", + "Democratic Republic Of The Congo": "democratic-republic-of-the-congo", + "Curacao": "curacao", + "Curaçao": "curacao", + "": "", + } + + for country, expected := range cases { + if slug := countryToSlug(country); slug != expected { + t.Errorf("%q: got %q, want %q", country, slug, expected) + } + } +} + +func TestCountryToSlugStripsParenthesesForFlagURLs(t *testing.T) { + // These 404'd while the slug was a plain space-to-hyphen replacement. + cases := map[string]string{ + "Myanmar (Burma)": "myanmar-burma", + "Cocos (Keeling) Islands": "cocos-keeling-islands", + "Falkland Islands (Malvinas)": "falkland-islands-malvinas", + "Timor-Leste (East Timor)": "timor-leste-east-timor", + } + + for country, expected := range cases { + if slug := countryToSlug(country); slug != expected { + t.Errorf("%q: got %q, want %q", country, slug, expected) + } + } +} + +func TestCountryConstantsRoundTripThroughTheSlugifier(t *testing.T) { + for _, country := range []Country{CountryBrazil, CountryMyanmarBurma, CountryUnitedStates} { + if slug := countryToSlug(string(country)); slug != string(country) { + t.Errorf("%q: got %q", country, slug) + } + } +} + +func equalOptional(got, want *float64) bool { + if got == nil || want == nil { + return got == nil && want == nil + } + return *got == *want +} + +// float64Ptr is the shorthand the table-driven tests need for optional numbers. +func float64Ptr(value float64) *float64 { return &value } + +func repeat(text string, times int) string { + result := make([]byte, 0, len(text)*times) + + for range times { + result = append(result, text...) + } + return string(result) +} + +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 + xxLA / LAN340 aircraft
+ Gol + xxG3 / GLO140 aircraft
+ Delta Air Lines + xxDL / DAL900 aircraft
+ Sky2 + xxSK10 aircraft
+ SkyTeam + xxSKT15 aircraft
placeholder
+ Should Skip +
+ diff --git a/go/flightradarapi/testdata/airports.json b/go/flightradarapi/testdata/airports.json new file mode 100644 index 0000000..924f9db --- /dev/null +++ b/go/flightradarapi/testdata/airports.json @@ -0,0 +1,77 @@ +{ + "version": "1786010717", + "rows": [ + { + "name": "Sao Paulo Guarulhos International Airport", + "iata": "GRU", + "icao": "SBGR", + "lat": -23.429991, + "lon": -46.4674, + "country": "Brazil", + "alt": 2436 + }, + { + "name": "Rio de Janeiro Galeao International Airport", + "iata": "GIG", + "icao": "SBGL", + "lat": -22.805696, + "lon": -43.25523, + "country": "Brazil", + "alt": 22 + }, + { + "name": "Sao Paulo Congonhas Airport", + "iata": "CGH", + "icao": "SBSP", + "lat": -23.627348, + "lon": -46.655987, + "country": "Brazil", + "alt": 2614 + }, + { + "name": "Los Angeles International Airport", + "iata": "LAX", + "icao": "KLAX", + "lat": 33.94252, + "lon": -118.406998, + "country": "United States", + "alt": 125 + }, + { + "name": "New York John F. Kennedy International Airport", + "iata": "JFK", + "icao": "KJFK", + "lat": 40.639751, + "lon": -73.7789, + "country": "United States", + "alt": 14 + }, + { + "name": "A Coruna Airport", + "iata": "LCG", + "icao": "LECO", + "lat": 43.302059, + "lon": -8.37725, + "country": "Spain", + "alt": 326 + }, + { + "name": "Ann Airport", + "iata": "VBA", + "icao": "VYAN", + "lat": 19.770643, + "lon": 94.026405, + "country": "Myanmar (Burma)", + "alt": "43" + }, + { + "name": "Broken Coords Airport", + "iata": "BAD", + "icao": "SBAD", + "lat": "n/a", + "lon": "", + "country": "Brazil", + "alt": 0 + } + ] +} diff --git a/go/flightradarapi/values.go b/go/flightradarapi/values.go new file mode 100644 index 0000000..cc1faf9 --- /dev/null +++ b/go/flightradarapi/values.go @@ -0,0 +1,90 @@ +package flightradarapi + +import ( + "encoding/json" + "strconv" +) + +// DefaultText is the placeholder the Get* formatters return for a value the +// feed did not send. +const DefaultText = "N/A" + +// missing reports whether a feed value carries no information. FR24 sends the +// literal "N/A" as often as it sends null. +func missing(value any) bool { + if value == nil { + return true + } + text, ok := value.(string) + return ok && text == DefaultText +} + +func asMap(value any) map[string]any { + if nested, ok := value.(map[string]any); ok { + return nested + } + return map[string]any{} +} + +func asSlice(value any) []any { + if items, ok := value.([]any); ok { + return items + } + return []any{} +} + +func getMap(source map[string]any, key string) map[string]any { + return asMap(source[key]) +} + +func getSlice(source map[string]any, key string) []any { + return asSlice(source[key]) +} + +// getString returns a text field, or "" when the feed sent nothing usable. +func getString(source map[string]any, key string) string { + value := source[key] + + if missing(value) { + return "" + } + + switch typed := value.(type) { + case string: + return typed + case json.Number: + // A payload decoded with UseNumber carries these where a plain decode + // would carry a float64. + return typed.String() + case float64: + return formatNumber(typed) + case bool: + return strconv.FormatBool(typed) + default: + return "" + } +} + +func getNumber(source map[string]any, key string) *float64 { + if value := source[key]; !missing(value) { + return toNumber(value) + } + return nil +} + +func getBool(source map[string]any, key string) *bool { + value := source[key] + + if missing(value) { + return nil + } + if typed, ok := value.(bool); ok { + return &typed + } + return nil +} + +// formatNumber renders a number the way both other ports do: 2436, not 2436.0. +func formatNumber(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) +} diff --git a/go/flightradarapi/zones.go b/go/flightradarapi/zones.go new file mode 100644 index 0000000..21d801e --- /dev/null +++ b/go/flightradarapi/zones.go @@ -0,0 +1,222 @@ +// Code generated from python/FlightRadarAPI/zones.py. DO NOT EDIT. +// +// TestStaticZonesMatchThePythonSource fails when the Python source moves ahead +// of this file, which is the signal to regenerate it. + +package flightradarapi + +// Zone is a rectangular region of the globe, as FlightRadar24 defines it. +type Zone struct { + TLY float64 `json:"tl_y"` + TLX float64 `json:"tl_x"` + BRY float64 `json:"br_y"` + BRX float64 `json:"br_x"` + Subzones map[string]Zone `json:"subzones,omitempty"` +} + +// staticZones mirrors the payload of Core.zones_data_url, bundled so +// GetZones needs no request. +var staticZones = map[string]Zone{ + "europe": { + TLY: 72.57, + TLX: -16.96, + BRY: 33.57, + BRX: 53.05, + Subzones: map[string]Zone{ + "poland": { + TLY: 56.86, + TLX: 11.06, + BRY: 48.22, + BRX: 28.26, + }, + "germany": { + TLY: 57.92, + TLX: 1.81, + BRY: 45.81, + BRX: 16.83, + }, + "uk": { + TLY: 62.61, + TLX: -13.07, + BRY: 49.71, + BRX: 3.46, + Subzones: map[string]Zone{ + "london": { + TLY: 53.06, + TLX: -2.87, + BRY: 50.07, + BRX: 3.26, + }, + "ireland": { + TLY: 56.22, + TLX: -11.71, + BRY: 50.91, + BRX: -4.4, + }, + }, + }, + "spain": { + TLY: 44.36, + TLX: -11.06, + BRY: 35.76, + BRX: 4.04, + }, + "france": { + TLY: 51.07, + TLX: -5.18, + BRY: 42.17, + BRX: 8.9, + }, + "ceur": { + TLY: 51.39, + TLX: 11.25, + BRY: 39.72, + BRX: 32.55, + }, + "scandinavia": { + TLY: 72.12, + TLX: -0.73, + BRY: 53.82, + BRX: 40.67, + }, + "italy": { + TLY: 47.67, + TLX: 5.26, + BRY: 36.27, + BRX: 20.64, + }, + }, + }, + "northamerica": { + TLY: 75.0, + TLX: -180.0, + BRY: 3.0, + BRX: -52.0, + Subzones: map[string]Zone{ + "na_n": { + TLY: 72.82, + TLX: -177.97, + BRY: 41.92, + BRX: -52.48, + }, + "na_c": { + TLY: 54.66, + TLX: -134.68, + BRY: 22.16, + BRX: -56.91, + Subzones: map[string]Zone{ + "na_cny": { + TLY: 45.06, + TLX: -83.69, + BRY: 35.96, + BRX: -64.29, + }, + "na_cla": { + TLY: 37.91, + TLX: -126.12, + BRY: 30.21, + BRX: -110.02, + }, + "na_cat": { + TLY: 35.86, + TLX: -92.61, + BRY: 22.56, + BRX: -71.19, + }, + "na_cse": { + TLY: 49.12, + TLX: -126.15, + BRY: 42.97, + BRX: -111.92, + }, + "na_nw": { + TLY: 54.12, + TLX: -134.13, + BRY: 38.32, + BRX: -96.75, + }, + "na_ne": { + TLY: 53.72, + TLX: -98.76, + BRY: 38.22, + BRX: -57.36, + }, + "na_sw": { + TLY: 38.92, + TLX: -133.98, + BRY: 22.62, + BRX: -96.75, + }, + "na_se": { + TLY: 38.52, + TLX: -98.62, + BRY: 22.52, + BRX: -57.36, + }, + "na_cc": { + TLY: 45.92, + TLX: -116.88, + BRY: 27.62, + BRX: -75.91, + }, + }, + }, + "na_s": { + TLY: 41.92, + TLX: -177.83, + BRY: 3.82, + BRX: -52.48, + }, + }, + }, + "southamerica": { + TLY: 16.0, + TLX: -96.0, + BRY: -57.0, + BRX: -31.0, + }, + "oceania": { + TLY: 19.62, + TLX: 88.4, + BRY: -55.08, + BRX: 180.0, + }, + "asia": { + TLY: 79.98, + TLX: 40.91, + BRY: 12.48, + BRX: 179.77, + Subzones: map[string]Zone{ + "japan": { + TLY: 60.38, + TLX: 113.5, + BRY: 22.58, + BRX: 176.47, + }, + }, + }, + "africa": { + TLY: 39.0, + TLX: -29.0, + BRY: -39.0, + BRX: 55.0, + }, + "atlantic": { + TLY: 52.62, + TLX: -50.9, + BRY: 15.62, + BRX: -4.75, + }, + "maldives": { + TLY: 10.72, + TLX: 63.1, + BRY: -6.08, + BRX: 86.53, + }, + "northatlantic": { + TLY: 82.62, + TLX: -84.53, + BRY: 59.02, + BRX: 4.45, + }, +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..34838a6 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,9 @@ +module github.com/JeanExtreme002/FlightRadarAPI/go + +go 1.25.0 + +require ( + github.com/andybalholm/brotli v1.2.2 + golang.org/x/net v0.58.0 + golang.org/x/text v0.41.0 +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..6ecbbf0 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,8 @@ +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= diff --git a/mkdocs.yml b/mkdocs.yml index f30ade3..bf52fc4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,7 @@ nav: - Home: index.md - Python: python.md - Node.js: nodejs.md + - Go: go.md - Projects Using FlightRadarAPI: projects.md theme: diff --git a/nodejs/README.md b/nodejs/README.md index 3abf5b9..d290dd5 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -1,15 +1,18 @@ # FlightRadarAPI -Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python 3 and Node.js. +Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Node.js. This SDK should only be used for your own educational purposes. If you are interested in accessing Flightradar24 data commercially, please contact business@fr24.com. See more information at [Flightradar24's terms and conditions](https://www.flightradar24.com/terms-and-conditions). **Official FR24 API**: https://fr24api.flightradar24.com/ [![Node.js Package](https://github.com/JeanExtreme002/FlightRadarAPI/actions/workflows/node-package.yml/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) -[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi) +[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) [![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](https://pypi.org/project/FlightRadarAPI/) @@ -64,4 +67,4 @@ Countries.FRANCE // "france" ``` ## Documentation -Explore the documentation of FlightRadarAPI package, for Python or NodeJS, through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/). +Explore the documentation of FlightRadarAPI package through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/). diff --git a/python/README.md b/python/README.md index 5c87ffa..4f045ef 100644 --- a/python/README.md +++ b/python/README.md @@ -1,15 +1,18 @@ # FlightRadarAPI -Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python 3 and Node.js. +Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python. This SDK should only be used for your own educational purposes. If you are interested in accessing Flightradar24 data commercially, please contact business@fr24.com. See more information at [Flightradar24's terms and conditions](https://www.flightradar24.com/terms-and-conditions). **Official FR24 API**: https://fr24api.flightradar24.com/ [![Python Package](https://github.com/JeanExtreme002/FlightRadarAPI/workflows/Python%20Package/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions) -[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI) +[![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/) [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/) [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi) +[![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi) +[![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi) +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/) [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/) [![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](https://pypi.org/project/FlightRadarAPI/) @@ -44,4 +47,4 @@ zones = fr_api.get_zones() ``` ## Documentation -Explore the documentation of FlightRadarAPI package, for Python or NodeJS, through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/). +Explore the documentation of FlightRadarAPI package through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/).