diff --git a/.github/workflows/go-integration-tests.yml b/.github/workflows/go-integration-tests.yml index d088c1a..fcfa40a 100644 --- a/.github/workflows/go-integration-tests.yml +++ b/.github/workflows/go-integration-tests.yml @@ -9,6 +9,10 @@ on: - '**/*.go' - 'go.mod' - 'go.sum' + # The "Test examples" step validates the in-documentation snippets, so doc changes must + # re-run the workflow even though Markdown is not Go code. + - 'docs/**' + - 'README.md' - '.github/workflows/go-integration-tests.yml' workflow_dispatch: @@ -77,5 +81,23 @@ jobs: env: # The integration-test token is stored as a repository secret. APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} - # Limit parallelism to be gentle on the shared test account. - run: go test ./tests/ -v -timeout 900s -p 1 -parallel 4 + # Limit parallelism to be gentle on the shared test account. The documentation example + # programs (TestExample*) and the in-documentation snippet checks (TestDocSnippets*) are + # exercised by the standalone "Test examples" step below, so they are skipped here to + # keep the two concerns separate. + run: go test ./tests/ -v -timeout 900s -p 1 -parallel 4 -skip '^(TestExample|TestDocSnippets)' + + # Standalone CI step that verifies the documentation examples actually work. It runs the + # example programs in examples/ end-to-end against the live API (via the TestExample* + # smoke tests in tests/examples_test.go, each of which executes `go run ./examples/`) + # and checks that every in-documentation code snippet is valid, runnable, and gofmt- + # formatted (the TestDocSnippets* tests, which extract every ```go block from the README + # and docs/ and compile each one). Both are required by the documentation requirements: + # each documentation example has a CI test that actually runs the code, and each snippet + # must be valid, runnable and properly formatted. + - name: Test examples + env: + APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} + # Match the integration-test thread cap so the example programs (which hit the live + # account) stay gentle on the shared test account. + run: go test ./tests/ -v -timeout 900s -p 1 -parallel 4 -run '^(TestExample|TestDocSnippets)' diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a2632d..52435d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,85 @@ All notable changes to the Apify Go client are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.1] - 2026-06-19 + +Documentation and CI compliance with the updated client requirements. No changes to the +public API or to the OpenAPI spec version (`v2-2026-06-18T095846Z`), so there are no breaking +changes. + +### Added + +- CI: a standalone `Test examples` step in the Go integration workflow that actually runs the + documentation example code end-to-end. It executes the example programs in `examples/` against + the live API (the `TestExample*` smoke tests, each running `go run ./examples/`) and + validates that every in-documentation `go` snippet is valid, runnable, and gofmt-formatted + (the new `TestDocSnippets*` tests, which extract every ```go block from the README and `docs/` + and compile each one). The `Integration tests` step now skips these so the two concerns stay + separate, mirroring the Rust sibling client. +- Tests: `tests/docs_snippets_test.go`, an offline doc-snippet harness (Go has no Markdown + doctest equivalent) that enforces the requirement that each in-documentation code snippet is + valid, runnable, and properly formatted. + +### Changed + +- Workflow now also triggers on `docs/**` and `README.md` changes, so documentation edits + re-run the snippet validation. + +### Fixed + +- Reformatted all `docs/` and README code snippets to canonical gofmt output (one-line + `if err != nil { ... }` blocks expanded, tab indentation, aligned trailing comments) and made + the custom-HTTP-transport snippet a complete, compilable program. +- Corrected the README versioning note, which referenced the older spec version + `v2-2026-06-16T064758Z` instead of the current `v2-2026-06-18T095846Z`. + +### Documentation + +- Documented the shared `ListOptions` type (fields + example) in `docs/README.md`, which several + resource pages reference as a method argument but which was previously undefined in the docs. +- Documented the `apify.ListDatasetItems[T]` generic helper's argument types and added a runnable + typed-decoding example in `docs/storages.md`. +- Added explicit field listings for the `ActorRun`, `Build`, `User`, and `ActorStoreListItem` + response models to `docs/runs.md`, `docs/builds.md`, and `docs/misc.md`. +- Added full field listings in `docs/storages.md` for the storage option/parameter structs that + were previously named in method tables but not enumerated: `DatasetListItemsOptions`, + `DatasetDownloadOptions`, `ListKeysOptions`, `GetRecordOptions`, `GetRecordsOptions`, + `ListRequestsOptions`, and the `RequestQueueRequest` payload. +- Documented the storage *return* types that examples dereference: `KeyValueStoreRecord`, + `KeyValueStoreKeysPage` (and `KeyValueStoreKey`), `RequestQueueHead`, + `RequestQueueOperationInfo`, and `BatchAddResult` in `docs/storages.md`. +- Added field tables for the remaining response models (`Actor`, `Task`, `Schedule`, `Webhook`, + `WebhookDispatch`) to their resource pages, matching the treatment of `ActorRun`/`Build`. +- Documented the accepted values / details of the enum-like parameters `RunListOptions.Status`, + `ListRequestsOptions.Filter` (`"locked"`/`"pending"`), `DatasetListItemsOptions.View`, and the + full `StorageListOptions` field table; and clarified in `docs/README.md` that the + within-storage listers (`ListKeys`, `ListHead`) return their own page/head containers rather + than `PaginationList[T]`. +- Expanded the run/Actor/store *input* option structs from bare name lists into full + field/type/meaning tables: `ActorStartOptions` (including the nested ad-hoc `Webhooks` element + shape and `ForcePermissionLevel`), `ActorBuildOptions`, `ActorListOptions`, `StoreListOptions` + (with enum values for `SortBy`/`PricingModel`), `RunResurrectOptions`, `MetamorphOptions`, and + `LogOptions`; and made `StorageListOptions.Ownership` state its accepted values definitively. +- Documented the schedule `actions` payload shape with a runnable `RUN_ACTOR` action example in + `docs/schedules.md`, replacing the empty `[]any{}` placeholder. +- Stated the closed enum sets definitively (verified against the OpenAPI spec) instead of hedging + with "e.g.": `StoreListOptions.PricingModel` (`FREE`, `FLAT_PRICE_PER_MONTH`, + `PRICE_PER_DATASET_ITEM`, `PAY_PER_EVENT` — previously omitted `PAY_PER_EVENT`), + `StoreListOptions.ResponseFormat` (`full`, `agent` — previously unspecified), + `ActorStartOptions.ForcePermissionLevel` (`LIMITED_PERMISSIONS`, `FULL_PERMISSIONS`), and + `ActorListOptions.SortBy` (`createdAt`, `stats.lastRunStartedAt`). +- Corrected and completed the run/build status enum documentation: the canonical `ActorJobStatus` + enum has eight values, but the docs (and the `ActorRun.Status` in-code comment) listed only six + for runs and four for builds. Now `ActorRun.Status`, `RunListOptions.Status`, and `Build.Status` + all document the full set `READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `TIMING-OUT`, `TIMED-OUT`, + `ABORTING`, `ABORTED`. (Behavior unchanged: `IsTerminal` still treats only the four terminal + states as finished, which is correct.) +- Documented the closed enums in the actor/version `Create` definition (`sourceType` = + `VersionSourceType`: `SOURCE_FILES`/`GIT_REPO`/`TARBALL`/`GITHUB_GIST`/`SOURCE_CODE`; source-file + `format` = `TEXT`/`BASE64`) with a runnable `SOURCE_FILES` example in `docs/actors.md`. +- Enumerated the closed 12-value `WebhookEventType` set in `docs/webhooks.md` and cross-referenced + it from the `ActorStartOptions.Webhooks` note. + ## [0.2.0] - 2026-06-19 Verified against OpenAPI specification version `v2-2026-06-18T095846Z` (bumped from diff --git a/README.md b/README.md index 61c81f0..a6141fc 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,10 @@ options for full control: ```go client := apify.NewClientWithOptions( apify.WithToken("my-api-token"), - apify.WithBaseURL("https://api.apify.com"), // /v2 is appended automatically - apify.WithMaxRetries(8), // default 8 + apify.WithBaseURL("https://api.apify.com"), // /v2 is appended automatically + apify.WithMaxRetries(8), // default 8 apify.WithMinDelayBetweenRetries(500*time.Millisecond), - apify.WithTimeout(360*time.Second), // default 6 minutes + apify.WithTimeout(360*time.Second), // default 6 minutes apify.WithUserAgentSuffix("MyTool/1.0"), apify.WithHTTPBackend(apify.NewDefaultHTTPBackend()), ) @@ -135,23 +135,35 @@ The transport is replaceable. Implement `HTTPBackend` (a single `Do` method) to custom client, proxy, or test double, and pass it with `WithHTTPBackend`: ```go +package main + +import ( + "net/http" + + apify "github.com/apify/apify-client-go" +) + +// myBackend is a custom HTTPBackend wrapping a standard *http.Client. type myBackend struct{ inner *http.Client } func (b *myBackend) Do(req *http.Request) (*http.Response, error) { return b.inner.Do(req) } -client := apify.NewClientWithOptions( - apify.WithToken("my-api-token"), - apify.WithHTTPBackend(&myBackend{inner: http.DefaultClient}), -) +func main() { + client := apify.NewClientWithOptions( + apify.WithToken("my-api-token"), + apify.WithHTTPBackend(&myBackend{inner: http.DefaultClient}), + ) + _ = client +} ``` ## Versioning - `apify.CLIENT_VERSION` — the semantic version of this library. - `apify.API_SPEC_VERSION` — the Apify OpenAPI spec version this client was built against - (`v2-2026-06-16T064758Z`). + (`v2-2026-06-18T095846Z`). ## Examples diff --git a/docs/README.md b/docs/README.md index ca78f32..f02e67f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,11 +29,36 @@ local: ```go page, err := client.Actors().List(ctx, apify.ActorListOptions{ - My: apify.Ptr(true), - Limit: apify.Ptr(int64(10)), + My: apify.Ptr(true), + Limit: apify.Ptr(int64(10)), }) ``` +## Common list options — `apify.ListOptions` + +Most `List` methods (builds, runs, tasks, schedules, webhooks, Actor versions) take the shared +`apify.ListOptions`, which carries the standard pagination/ordering controls. All fields are +optional pointers; leave a field `nil` to use the API default. Use `apify.Ptr` to set them +inline. + +| Field | Type | Meaning | +|---|---|---| +| `Offset` | `*int64` | Number of items to skip from the start of the list. | +| `Limit` | `*int64` | Maximum number of items to return. | +| `Desc` | `*bool` | If `true`, return items newest-first. | + +```go +page, err := client.Builds().List(ctx, apify.ListOptions{ + Limit: apify.Ptr(int64(50)), + Desc: apify.Ptr(true), +}) +``` + +Collections with extra filters use a dedicated options type instead of (or in addition to) +`ListOptions`: `ActorListOptions` (Actors), `StorageListOptions` (datasets/key-value +stores/request queues), `StoreListOptions` (the Store), and `RunListOptions` (runs, passed +alongside `ListOptions`). Each is documented on its resource page. + ## Pagination List/iterate methods return `apify.PaginationList[T]`, one page plus the API's pagination @@ -52,6 +77,13 @@ metadata: > immediately after a `PushItems` (or other write) `Total` may not yet include the new items. > Re-read after a short delay if you need an exact post-write total. +The *collection* `List` methods (Actors, builds, runs, tasks, schedules, webhooks, datasets, +key-value stores, request queues) return `PaginationList[T]`. The *within-storage* listers use +their own page/head containers instead, because the underlying API endpoints paginate +differently: `KeyValueStoreClient.ListKeys` returns `KeyValueStoreKeysPage` (key-based +pagination) and `RequestQueueClient.ListHead` returns `RequestQueueHead`. Both are documented on +the [storages](storages.md) page. + ## Pages - [Actors](actors.md) — Actors, versions, environment variables. diff --git a/docs/actors.md b/docs/actors.md index 116a13f..9d83206 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -11,12 +11,67 @@ Actors are the programs that run on the Apify platform. Access the Actor collect | `List(ctx, ActorListOptions) (PaginationList[Actor], error)` | List the account's Actors. | | `Create(ctx, definition any) (Actor, error)` | Create a new Actor. | -`ActorListOptions`: `Offset`, `Limit`, `Desc`, `My`, `SortBy` (all optional pointers). +`Create` takes a free-form definition (`any`) serialized to JSON, so the Actor's fields are +passed as a map (`name`, `title`, `versions`, etc.). A version's `sourceType` selects how its +source is supplied, and is one of the closed `VersionSourceType` values: `SOURCE_FILES`, +`GIT_REPO`, `TARBALL`, `GITHUB_GIST`, `SOURCE_CODE`. For `SOURCE_FILES`, each entry in +`sourceFiles` has a `format` of `TEXT` or `BASE64`. A minimal `SOURCE_FILES` Actor: + +```go +actor, err := client.Actors().Create(ctx, map[string]any{ + "name": "my-actor", + "title": "My Actor", + "versions": []any{ + map[string]any{ + "versionNumber": "0.0", + "sourceType": "SOURCE_FILES", // VersionSourceType: SOURCE_FILES|GIT_REPO|TARBALL|GITHUB_GIST|SOURCE_CODE + "sourceFiles": []any{ + map[string]any{ + "name": "Dockerfile", + "format": "TEXT", // SourceCodeFileFormat: TEXT|BASE64 + "content": "FROM apify/actor-node:20\n", + }, + }, + }, + }, +}) +if err != nil { + log.Fatal(err) +} +_ = actor +``` + +`ActorListOptions` (all fields optional pointers): + +| Field | Type | Meaning | +|---|---|---| +| `Offset` | `*int64` | Number of Actors to skip. | +| `Limit` | `*int64` | Maximum number of Actors to return. | +| `Desc` | `*bool` | Return Actors newest-first. | +| `My` | `*bool` | Return only Actors owned by the current user. | +| `SortBy` | `*string` | Sort field. Accepted values: `createdAt`, `stats.lastRunStartedAt`. | ```go page, err := client.Actors().List(ctx, apify.ActorListOptions{My: apify.Ptr(true), Limit: apify.Ptr(int64(10))}) ``` +### `Actor` fields + +The `Actor` value returned by `Get`/`Create`/`Update` and listed by `List`: + +| Field | Type | Meaning | +|---|---|---| +| `ID` | `string` | Unique Actor ID. | +| `UserID` | `string` | ID of the user who owns the Actor. | +| `Name` | `string` | Technical name of the Actor (used in API paths). | +| `Username` | `string` | Username of the Actor's owner. | +| `Title` | `string` | Human-readable title shown in the UI. | +| `Description` | `string` | What the Actor does. | +| `IsPublic` | `bool` | Whether the Actor is public in Apify Store. | +| `CreatedAt` | `*time.Time` | When the Actor was created. | +| `ModifiedAt` | `*time.Time` | When the Actor was last modified. | +| `Extra` | `map[string]json.RawMessage` | Any other fields returned by the API. | + ## Single Actor | Method | Description | @@ -47,14 +102,35 @@ result, err := client.Actor("apify/hello-world").ValidateInputForBuild(ctx, map[string]any{"message": "hi"}, "latest", ) -if err != nil { log.Fatal(err) } +if err != nil { + log.Fatal(err) +} fmt.Println(string(result)) // raw JSON validation result ``` -`ActorStartOptions`: `Build`, `MemoryMbytes`, `TimeoutSecs`, `WaitForFinish`, `MaxItems`, -`MaxTotalChargeUsd`, `ContentType`, `RestartOnError`, `ForcePermissionLevel`, `Webhooks`. - -`ActorBuildOptions`: `BetaPackages`, `Tag`, `UseCache`, `WaitForFinish`. +`ActorStartOptions` (all fields optional): + +| Field | Type | Meaning | +|---|---|---| +| `Build` | `*string` | Tag or number of the build to run (e.g. `"latest"`, `"0.1.2"`). | +| `MemoryMbytes` | `*int64` | Memory in megabytes allocated for the run. | +| `TimeoutSecs` | `*int64` | Run timeout in seconds (`0` means no timeout). | +| `WaitForFinish` | `*int64` | Max seconds to wait server-side for the run to finish (max 60). | +| `MaxItems` | `*int64` | Maximum dataset items to charge (pay-per-result Actors). | +| `MaxTotalChargeUsd` | `*float64` | Maximum total charge in USD (pay-per-event Actors). | +| `ContentType` | `*string` | Content type of the input body (default `application/json`). | +| `RestartOnError` | `*bool` | Restart the run if it fails. | +| `ForcePermissionLevel` | `*string` | Override the Actor's permission level for this run. Accepted values: `LIMITED_PERMISSIONS`, `FULL_PERMISSIONS`. | +| `Webhooks` | `[]any` | Ad-hoc webhooks to attach to this run. Each element is a map describing one webhook: `{"eventTypes": []string, "requestUrl": string, "payloadTemplate": string}` (same shape as a webhook definition; `eventTypes` are the `WebhookEventType` values listed in [webhooks.md](webhooks.md)). | + +`ActorBuildOptions` (all fields optional): + +| Field | Type | Meaning | +|---|---|---| +| `BetaPackages` | `*bool` | Use beta versions of Apify packages. | +| `Tag` | `*string` | Tag to apply to the build (e.g. `"latest"`). | +| `UseCache` | `*bool` | Whether to use the Docker build cache (default `true`). | +| `WaitForFinish` | `*int64` | Max seconds to wait server-side for the build (max 60). | ```go // Start an Actor with input and wait for it to finish. diff --git a/docs/builds.md b/docs/builds.md index 3cc1f1b..82b2304 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -34,4 +34,18 @@ fmt.Println("build status:", finished.Status) logText, ok, err := client.Build(build.ID).Log().Get(ctx) ``` +### `Build` fields + +The `Build` value returned by the build methods carries the build's metadata: + +| Field | Type | Meaning | +|---|---|---| +| `ID` | `string` | Unique build ID. | +| `ActID` | `string` | ID of the Actor this build belongs to. | +| `Status` | `string` | Build status. One of the eight `ActorJobStatus` values (shared with runs): `READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `TIMING-OUT`, `TIMED-OUT`, `ABORTING`, `ABORTED`. | +| `StartedAt` | `*time.Time` | When the build started. | +| `FinishedAt` | `*time.Time` | When the build finished (`nil` while still building). | +| `BuildNumber` | `string` | Human-readable build number (e.g. `"0.1.2"`). | +| `Extra` | `map[string]json.RawMessage` | Any other fields returned by the API (forward compatibility). | + `Build.IsTerminal()` reports whether a build has finished. diff --git a/docs/misc.md b/docs/misc.md index abafa29..c22c2f6 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -9,15 +9,41 @@ Browse public Actors with `client.Store()`: | `List(ctx, StoreListOptions) (PaginationList[ActorStoreListItem], error)` | One page of Store Actors. | | `Iterate(StoreListOptions) *StoreActorIterator` | Lazy iterator over all matching Actors. | -`StoreListOptions`: `Offset`, `Limit`, `Search`, `SortBy`, `Category`, `Username`, -`PricingModel`, `IncludeUnrunnableActors`, `AllowsAgenticUsers`, `ResponseFormat`. +`StoreListOptions` (all fields optional): + +| Field | Type | Meaning | +|---|---|---| +| `Offset` | `*int64` | Number of Actors to skip. | +| `Limit` | `*int64` | Maximum number of Actors to return. | +| `Search` | `*string` | Full-text search query. | +| `SortBy` | `*string` | Sort field (e.g. `"popularity"`, `"newest"`). | +| `Category` | `*string` | Filter Actors by category. | +| `Username` | `*string` | Filter Actors by owner username. | +| `PricingModel` | `*string` | Filter by pricing model. Accepted values: `FREE`, `FLAT_PRICE_PER_MONTH`, `PRICE_PER_DATASET_ITEM`, `PAY_PER_EVENT`. | +| `IncludeUnrunnableActors` | `*bool` | Include Actors the current user cannot run. | +| `AllowsAgenticUsers` | `*bool` | Filter to Actors that allow agentic users. | +| `ResponseFormat` | `*string` | Select the response format. Accepted values: `full`, `agent`. | + +Each item is an `ActorStoreListItem`: + +| Field | Type | Meaning | +|---|---|---| +| `ID` | `string` | Unique Actor ID. | +| `Name` | `string` | Technical name of the Actor. | +| `Username` | `string` | Username of the Actor's owner. | +| `Title` | `string` | Human-readable title (may be empty; fall back to `Name`). | +| `Extra` | `map[string]json.RawMessage` | Any other fields returned by the API (forward compatibility). | ```go it := client.Store().Iterate(apify.StoreListOptions{Limit: apify.Ptr(int64(20)), Search: apify.Ptr("scraper")}) for { actor, err := it.Next(ctx) - if err != nil { log.Fatal(err) } - if actor == nil { break } + if err != nil { + log.Fatal(err) + } + if actor == nil { + break + } fmt.Println(actor.Title, actor.ID) } ``` @@ -34,6 +60,15 @@ for { | `Limits(ctx) (json.RawMessage, error)` | Current account's limits (`Me()` only). | | `UpdateLimits(ctx, newLimits any) error` | Update the account's limits (`Me()` only). | +The `User` value returned by `Get` carries the account's public fields; for `Me()` the API +also returns private account details, which are preserved in `Extra`: + +| Field | Type | Meaning | +|---|---|---| +| `ID` | `string` | Unique user ID. | +| `Username` | `string` | The user's username. | +| `Extra` | `map[string]json.RawMessage` | Any other fields returned by the API (private details for `Me()`). | + The usage/limits methods return an error if called on a non-`Me()` client. `MonthlyUsage` is equivalent to `MonthlyUsageForDate(ctx, "")`: an empty `date` omits the @@ -43,16 +78,22 @@ object with the account's usage breakdown and totals for the period. ```go user, ok, err := client.Me().Get(ctx) -if err != nil { log.Fatal(err) } +if err != nil { + log.Fatal(err) +} fmt.Println(user.Username, ok) usage, err := client.Me().MonthlyUsage(ctx) -if err != nil { log.Fatal(err) } +if err != nil { + log.Fatal(err) +} fmt.Println(string(usage)) // raw JSON usage report for the current month // Usage for the month containing a specific date. mayUsage, err := client.Me().MonthlyUsageForDate(ctx, "2026-05-15") -if err != nil { log.Fatal(err) } +if err != nil { + log.Fatal(err) +} fmt.Println(string(mayUsage)) // raw JSON usage report ``` @@ -68,6 +109,13 @@ fmt.Println(string(mayUsage)) // raw JSON usage report | `Stream(ctx) (io.ReadCloser, error)` | A live stream of the log; close when done. | | `StreamWithOptions(ctx, LogOptions) (io.ReadCloser, error)` | Stream with options (`Raw`, `Download`). | +`LogOptions` (all fields optional): + +| Field | Type | Meaning | +|---|---|---| +| `Raw` | `*bool` | Return the unprocessed log content (no platform post-processing). | +| `Download` | `*bool` | Set `Content-Disposition` so the log is served as a download. | + For convenient live redirection of a run's log, `client.Run(id).GetStreamedLog(ctx)` returns a raw live stream directly. @@ -77,7 +125,9 @@ logText, ok, err := client.Run(runID).Log().Get(ctx) // Or stream it live (log redirection). stream, err := client.Run(runID).Log().Stream(ctx) -if err != nil { log.Fatal(err) } +if err != nil { + log.Fatal(err) +} defer stream.Close() _, _ = io.Copy(os.Stdout, stream) ``` diff --git a/docs/runs.md b/docs/runs.md index a1eaf7a..ab64a41 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -10,8 +10,15 @@ with `client.Run(id)`, and an Actor's or task's runs with `client.Actor(id).Runs | --- | --- | | `List(ctx, ListOptions, RunListOptions) (PaginationList[ActorRun], error)` | List runs. | -`RunListOptions`: `Status`, `StartedAfter`, `StartedBefore` (the time filters apply only to -Actor- and task-scoped collections). +`RunListOptions`: + +| Field | Type | Meaning | +|---|---|---| +| `Status` | `[]string` | Filter by one or more run statuses; sent as a comma-separated list. Values are the eight `ActorJobStatus` values: `READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `TIMING-OUT`, `TIMED-OUT`, `ABORTING`, `ABORTED`. | +| `StartedAfter` | `*string` | Only runs started after this ISO-8601 timestamp. | +| `StartedBefore` | `*string` | Only runs started before this ISO-8601 timestamp. | + +The time filters apply only to Actor- and task-scoped collections. ## Single run @@ -33,10 +40,23 @@ Actor- and task-scoped collections). | `Log() *LogClient` | The run's log. | | `GetStreamedLog(ctx) (io.ReadCloser, error)` | Live stream of the run's raw log (log redirection). | -`RunResurrectOptions`: `Build`, `MemoryMbytes`, `TimeoutSecs`, `MaxItems`, -`MaxTotalChargeUsd`, `RestartOnError`. +`RunResurrectOptions` (all fields optional): + +| Field | Type | Meaning | +|---|---|---| +| `Build` | `*string` | Tag or number of the build to resurrect with. | +| `MemoryMbytes` | `*int64` | Memory in megabytes to allocate. | +| `TimeoutSecs` | `*int64` | Run timeout in seconds. | +| `MaxItems` | `*int64` | Maximum dataset items to charge (pay-per-result Actors). | +| `MaxTotalChargeUsd` | `*float64` | Maximum total charge in USD (pay-per-event Actors). | +| `RestartOnError` | `*bool` | Restart the run if it fails. | + +`MetamorphOptions`: -`MetamorphOptions`: `Build`, `ContentType`. +| Field | Type | Meaning | +|---|---|---| +| `Build` | `string` | Pin the target Actor's build (empty for default). | +| `ContentType` | `string` | Content type of the input body (default `application/json`). | `RunChargeOptions`: `EventName` (required), `Count`, `IdempotencyKey` (auto-generated if empty, so a retried charge is applied at most once). @@ -51,6 +71,28 @@ if err != nil { items, err := client.Run(run.ID).Dataset().ListItems(ctx, apify.DatasetListItemsOptions{}) ``` +### `ActorRun` fields + +The `ActorRun` value returned by the run methods (and by `Actor(id).Call`/`Start`) carries the +run's metadata. The commonly used fields: + +| Field | Type | Meaning | +|---|---|---| +| `ID` | `string` | Unique run ID. | +| `ActID` | `string` | ID of the Actor that produced the run. | +| `ActorTaskID` | `string` | ID of the task that started the run, if any. | +| `UserID` | `string` | ID of the user who owns the run. | +| `Status` | `string` | Run status. One of the eight `ActorJobStatus` values: `READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `TIMING-OUT`, `TIMED-OUT`, `ABORTING`, `ABORTED`. | +| `StatusMessage` | `string` | Optional human-readable status message. | +| `StartedAt` | `*time.Time` | When the run started. | +| `FinishedAt` | `*time.Time` | When the run finished (`nil` while still running). | +| `BuildID` | `string` | ID of the build used for the run. | +| `DefaultDatasetID` | `string` | ID of the run's default dataset. | +| `DefaultKeyValueStoreID` | `string` | ID of the run's default key-value store. | +| `DefaultRequestQueueID` | `string` | ID of the run's default request queue. | +| `ContainerURL` | `string` | URL of the run's container (for live access). | +| `Extra` | `map[string]json.RawMessage` | Any other fields returned by the API (forward compatibility). | + `ActorRun.IsTerminal()` reports whether a run has finished. Status message convenience: `client.SetStatusMessage(ctx, message, isTerminal)` updates the current run identified by the `ACTOR_RUN_ID` environment variable. diff --git a/docs/schedules.md b/docs/schedules.md index abd84e1..aee2800 100644 --- a/docs/schedules.md +++ b/docs/schedules.md @@ -10,6 +10,17 @@ Schedules start Actor or task runs at specified times. Access the schedule colle | `List(ctx, ListOptions) (PaginationList[Schedule], error)` | List the account's schedules. | | `Create(ctx, definition any) (Schedule, error)` | Create a new schedule. | +### `Schedule` fields + +| Field | Type | Meaning | +|---|---|---| +| `ID` | `string` | Unique schedule ID. | +| `UserID` | `string` | ID of the user who owns the schedule. | +| `Name` | `string` | The schedule name. | +| `CronExpression` | `string` | Cron expression governing when the schedule fires. | +| `IsEnabled` | `bool` | Whether the schedule is currently active. | +| `Extra` | `map[string]json.RawMessage` | Any other fields returned by the API. | + ## Single schedule | Method | Description | @@ -19,13 +30,43 @@ Schedules start Actor or task runs at specified times. Access the schedule colle | `Delete(ctx) error` | Delete the schedule. | | `GetLog(ctx) (string, bool, error)` | The schedule's invocation log. | +`Create`/`Update` take a free-form definition (`any`) that is serialized to JSON, so the +schedule's fields are passed as a map. The key fields are `name`, `cronExpression`, `isEnabled`, +`isExclusive` (whether overlapping invocations are skipped), and `actions` — the list of things +the schedule does when it fires. + +Each entry in `actions` describes one action. The common type is `RUN_ACTOR` (or +`RUN_ACTOR_TASK`), which starts an Actor (or task) run: + +| Action field | Type | Meaning | +|---|---|---| +| `type` | `string` | `"RUN_ACTOR"` or `"RUN_ACTOR_TASK"`. | +| `actorId` | `string` | ID of the Actor to run (for `RUN_ACTOR`). | +| `actorTaskId` | `string` | ID of the task to run (for `RUN_ACTOR_TASK`). | +| `runInput` | `object` | Optional run input (`body`, `contentType`). | +| `runOptions` | `object` | Optional run options (`build`, `memoryMbytes`, `timeoutSecs`). | + ```go sch, err := client.Schedules().Create(ctx, map[string]any{ "name": "nightly", "cronExpression": "0 0 * * *", "isEnabled": true, "isExclusive": true, - "actions": []any{}, + "actions": []any{ + map[string]any{ + "type": "RUN_ACTOR", + "actorId": "apify/hello-world", + "runInput": map[string]any{ + "body": `{"message":"hi"}`, + "contentType": "application/json", + }, + "runOptions": map[string]any{ + "build": "latest", + "memoryMbytes": 256, + "timeoutSecs": 60, + }, + }, + }, }) if err != nil { log.Fatal(err) diff --git a/docs/storages.md b/docs/storages.md index 1a97cae..9477a75 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -20,7 +20,50 @@ Single dataset: `client.Dataset(id)`: | `GetStatistics(ctx) (json.RawMessage, bool, error)` | Dataset statistics. | | `CreateItemsPublicURL(ctx, DatasetListItemsOptions, expiresInSecs *int64) (string, error)` | Signed public items URL. | -For typed item decoding use the generic helper `apify.ListDatasetItems[T](ctx, dataset, opts)`. +`DatasetListItemsOptions` controls filtering, projection, and pagination of the items +(`ListItems`, `ListDatasetItems`, and the `Items` field of `DatasetDownloadOptions`). All +fields are optional; the slice fields default to empty and the pointer fields to unset. + +| Field | Type | Meaning | +|---|---|---| +| `Offset` | `*int64` | Number of items to skip. | +| `Limit` | `*int64` | Maximum number of items to return. | +| `Desc` | `*bool` | Return items newest-first. | +| `Fields` | `[]string` | Restrict the output to these fields. | +| `OutputFields` | `[]string` | Positionally rename the fields selected by `Fields` (requires `Fields`). | +| `Omit` | `[]string` | Exclude these fields from the output. | +| `SkipEmpty` | `*bool` | Skip empty items. | +| `SkipHidden` | `*bool` | Skip hidden fields (those starting with `#`). | +| `Clean` | `*bool` | Return only clean (non-empty, non-hidden) items. | +| `Unwind` | `[]string` | Expand these fields (each array element becomes a separate item). | +| `Flatten` | `[]string` | Flatten these nested fields into dot-notation keys. | +| `View` | `*string` | Select a predefined dataset view (a named field-selection/transform defined in the Actor's dataset schema) by name. | +| `Simplified` | `*bool` | Return simplified (flattened, cleaned) items. | +| `SkipFailedPages` | `*bool` | Skip items that come from failed pages. | +| `Signature` | `*string` | Pre-shared URL signature granting access without an API token. | + +For typed item decoding use the generic helper +`apify.ListDatasetItems[T](ctx, dataset *DatasetClient, opts DatasetListItemsOptions) (PaginationList[T], error)`. +`ListItems` returns each item as `json.RawMessage`; this helper decodes every item into your +own type `T` instead. Pass the `*DatasetClient` you get from `client.Dataset(id)` as the +`dataset` argument: + +```go +// A struct matching the shape of your dataset items. +type Result struct { + Title string `json:"title"` +} + +page, err := apify.ListDatasetItems[Result](ctx, client.Dataset("DATASET_ID"), apify.DatasetListItemsOptions{ + Limit: apify.Ptr(int64(100)), +}) +if err != nil { + log.Fatal(err) +} +for _, item := range page.Items { + fmt.Println(item.Title) +} +``` `DownloadItems` takes a `DownloadItemsFormat`. The exported constants are: @@ -34,6 +77,21 @@ For typed item decoding use the generic helper `apify.ListDatasetItems[T](ctx, d | `apify.FormatRSS` | `rss` | | `apify.FormatHTML` | `html` | +`DatasetDownloadOptions` adds format-specific export options on top of the shared item +filtering/projection options. All fields are optional: + +| Field | Type | Meaning | +|---|---|---| +| `Items` | `DatasetListItemsOptions` | The shared filtering/projection options (see above). | +| `Attachment` | `*bool` | Set `Content-Disposition: attachment` on the response. | +| `Bom` | `*bool` | Prepend a UTF-8 BOM (useful for Excel-compatible CSV). | +| `Delimiter` | `*string` | CSV field delimiter (default `,`). | +| `SkipHeaderRow` | `*bool` | Omit the CSV header row. | +| `XMLRoot` | `*string` | Name of the root XML element (default `items`). | +| `XMLRow` | `*string` | Name of the per-item XML element (default `item`). | +| `FeedTitle` | `*string` | Title used for RSS/Atom feed exports. | +| `FeedDescription` | `*string` | Description used for RSS/Atom feed exports. | + ```go ds, _ := client.Datasets().GetOrCreate(ctx, "") defer client.Dataset(ds.ID).Delete(ctx) @@ -63,6 +121,44 @@ Single store: `client.KeyValueStore(id)`: | `GetRecordPublicURL(ctx, key) (string, error)` | Signed public record URL. | | `CreateKeysPublicURL(ctx, expiresInSecs *int64) (string, error)` | Signed public key-list URL. | +Option structs (all fields optional): + +| Struct | Field | Type | Meaning | +|---|---|---|---| +| `ListKeysOptions` | `Limit` | `*int64` | Maximum number of keys to return. | +| | `ExclusiveStartKey` | `*string` | List keys after this one (pagination). | +| | `Prefix` | `*string` | Restrict the listing to keys with this prefix. | +| | `Collection` | `*string` | Restrict the listing to a named collection of keys. | +| | `Signature` | `*string` | Pre-shared URL signature (access without a token). | +| `GetRecordOptions` | `Attachment` | `*bool` | Control the `Content-Disposition: attachment` behaviour. | +| | `Signature` | `*string` | Pre-shared URL signature (access without a token). | +| `GetRecordsOptions` | `Collection` | `*string` | Restrict the download to a named collection. | +| | `Prefix` | `*string` | Restrict the download to records with this key prefix. | +| | `Signature` | `*string` | Pre-shared URL signature (access without a token). | + +Return types: + +`GetRecord`/`GetRecordWithOptions` return a `*KeyValueStoreRecord`: + +| Field | Type | Meaning | +|---|---|---| +| `Key` | `string` | The record key. | +| `Value` | `[]byte` | The raw record bytes (decode according to `ContentType`). | +| `ContentType` | `string` | The record's MIME type, as reported by the API. | + +`ListKeys` returns a `KeyValueStoreKeysPage`: + +| Field | Type | Meaning | +|---|---|---| +| `Limit` | `int64` | Maximum number of keys requested. | +| `IsTruncated` | `bool` | Whether more keys are available. | +| `ExclusiveStartKey` | `string` | The key the listing started after. | +| `NextExclusiveStartKey` | `string` | Key to pass to fetch the next page. | +| `Items` | `[]KeyValueStoreKey` | The listed keys. | + +Each `KeyValueStoreKey` element has `Key` (`string`) and `Size` (`int64`, the record size in +bytes), plus an `Extra` (`map[string]json.RawMessage`) catch-all. + ```go store, _ := client.KeyValueStores().GetOrCreate(ctx, "") defer client.KeyValueStore(store.ID).Delete(ctx) @@ -95,6 +191,53 @@ Single queue: `client.RequestQueue(id)`: | `ListAndLockHead / ProlongRequestLock / DeleteRequestLock / UnlockRequests(ctx, ...)` | Locking. | | `WithClientKey(key string) *RequestQueueClient` | Pin a stable client key (required to unlock own locks). | +`RequestQueueRequest` is the request payload/record. `URL` is required; `ID` is assigned by the +API (omit it on create): + +| Field | Type | Meaning | +|---|---|---| +| `ID` | `string` | Unique request ID (assigned by the API; omitted on create). | +| `URL` | `string` | The request URL (required). | +| `UniqueKey` | `string` | Deduplication key for the request. | +| `Method` | `string` | HTTP method (e.g. `GET`, `POST`). | +| `UserData` | `json.RawMessage` | Arbitrary user-attached metadata. | +| `Extra` | `map[string]json.RawMessage` | Any other fields returned by the API. | + +`ListRequestsOptions` (all fields optional): + +| Field | Type | Meaning | +|---|---|---| +| `Limit` | `*int64` | Maximum number of requests to return. | +| `ExclusiveStartID` | `*string` | List requests after this ID. | +| `Cursor` | `*string` | Opaque pagination cursor (alternative to `ExclusiveStartID`). | +| `Filter` | `*string` | Restrict the listing. The API accepts only `"locked"` or `"pending"`. | + +Return types: + +`ListHead`/`ListAndLockHead` return a `RequestQueueHead`: + +| Field | Type | Meaning | +|---|---|---| +| `Limit` | `int64` | Maximum number of requests requested. | +| `HadMultipleClients` | `bool` | Whether multiple clients have accessed the queue. | +| `Items` | `[]RequestQueueRequest` | The requests at the head of the queue. | +| `Extra` | `map[string]json.RawMessage` | Any other fields returned by the API. | + +`AddRequest`/`UpdateRequest` return a `RequestQueueOperationInfo`: + +| Field | Type | Meaning | +|---|---|---| +| `RequestID` | `string` | ID of the affected request. | +| `WasAlreadyPresent` | `bool` | Whether the request was already in the queue. | +| `WasAlreadyHandled` | `bool` | Whether the request had already been handled. | + +`BatchAddRequests` returns a `BatchAddResult`: + +| Field | Type | Meaning | +|---|---|---| +| `ProcessedRequests` | `[]RequestQueueOperationInfo` | Requests the API successfully added. | +| `UnprocessedRequests` | `[]RequestQueueRequest` | Requests the API did not process. | + ```go rq, _ := client.RequestQueues().GetOrCreate(ctx, "") defer client.RequestQueue(rq.ID).Delete(ctx) @@ -105,11 +248,23 @@ _, _ = queue.AddRequest(ctx, apify.RequestQueueRequest{URL: "https://example.com it := queue.PaginateRequests(apify.Ptr(int64(100))) for { req, err := it.Next(ctx) - if err != nil { log.Fatal(err) } - if req == nil { break } + if err != nil { + log.Fatal(err) + } + if req == nil { + break + } fmt.Println(req.URL) } ``` -`StorageListOptions` (shared by the three collections): `Offset`, `Limit`, `Desc`, -`Unnamed`, `Ownership`. +`StorageListOptions` is shared by the three storage collections' `List` methods. All fields are +optional: + +| Field | Type | Meaning | +|---|---|---| +| `Offset` | `*int64` | Number of items to skip from the start of the list. | +| `Limit` | `*int64` | Maximum number of items to return. | +| `Desc` | `*bool` | Return items newest-first. | +| `Unnamed` | `*bool` | Include unnamed storages in the result. | +| `Ownership` | `*string` | Filter by ownership; accepted values are `OWNED` and `ACCESSIBLE`. | diff --git a/docs/tasks.md b/docs/tasks.md index f66c62f..6806ec4 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -10,6 +10,19 @@ A task is a pre-configured Actor run with stored input. Access the task collecti | `List(ctx, ListOptions) (PaginationList[Task], error)` | List the account's tasks. | | `Create(ctx, definition any) (Task, error)` | Create a new task. | +### `Task` fields + +| Field | Type | Meaning | +|---|---|---| +| `ID` | `string` | Unique task ID. | +| `ActID` | `string` | ID of the Actor this task runs. | +| `UserID` | `string` | ID of the user who owns the task. | +| `Name` | `string` | Technical name of the task. | +| `Title` | `string` | Human-readable title shown in the UI. | +| `CreatedAt` | `*time.Time` | When the task was created. | +| `ModifiedAt` | `*time.Time` | When the task was last modified. | +| `Extra` | `map[string]json.RawMessage` | Any other fields returned by the API. | + ## Single task | Method | Description | diff --git a/docs/webhooks.md b/docs/webhooks.md index 0acdadc..0408b05 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -14,6 +14,42 @@ Webhooks notify external services when events occur. Access the webhook collecti An Actor's or task's webhooks are also listable via `client.Actor(id).Webhooks()` / `client.Task(id).Webhooks()`. +### Event types + +A webhook's `eventTypes` is a list drawn from the closed `WebhookEventType` enum (12 values): + +| Build events | Run events | Other | +|---|---|---| +| `ACTOR.BUILD.CREATED` | `ACTOR.RUN.CREATED` | `TEST` | +| `ACTOR.BUILD.SUCCEEDED` | `ACTOR.RUN.SUCCEEDED` | | +| `ACTOR.BUILD.FAILED` | `ACTOR.RUN.FAILED` | | +| `ACTOR.BUILD.ABORTED` | `ACTOR.RUN.ABORTED` | | +| `ACTOR.BUILD.TIMED_OUT` | `ACTOR.RUN.TIMED_OUT` | | +| | `ACTOR.RUN.RESURRECTED` | | + +The same values apply to the ad-hoc `ActorStartOptions.Webhooks` element (see +[actors.md](actors.md)). + +### `Webhook` and `WebhookDispatch` fields + +`Webhook`: + +| Field | Type | Meaning | +|---|---|---| +| `ID` | `string` | Unique webhook ID. | +| `UserID` | `string` | ID of the user who owns the webhook. | +| `RequestURL` | `string` | URL the webhook posts to. | +| `EventTypes` | `[]string` | Events that trigger the webhook. | +| `Extra` | `map[string]json.RawMessage` | Any other fields returned by the API. | + +`WebhookDispatch`: + +| Field | Type | Meaning | +|---|---|---| +| `ID` | `string` | Unique dispatch ID. | +| `WebhookID` | `string` | ID of the webhook that produced this dispatch. | +| `Extra` | `map[string]json.RawMessage` | Any other fields returned by the API. | + ## Single webhook | Method | Description | diff --git a/models.go b/models.go index 59bf434..81bbe55 100644 --- a/models.go +++ b/models.go @@ -76,7 +76,8 @@ type ActorRun struct { ActorTaskID string `json:"actorTaskId"` // UserID is the ID of the user who owns the run. UserID string `json:"userId"` - // Status is the current run status (READY, RUNNING, SUCCEEDED, FAILED, ABORTED, TIMED-OUT). + // Status is the current run status. One of the eight ActorJobStatus values: READY, RUNNING, + // SUCCEEDED, FAILED, TIMING-OUT, TIMED-OUT, ABORTING, ABORTED. Status string `json:"status"` // StatusMessage is an optional human-readable status message. StatusMessage string `json:"statusMessage"` diff --git a/tests/docs_snippets_test.go b/tests/docs_snippets_test.go new file mode 100644 index 0000000..ad34dfa --- /dev/null +++ b/tests/docs_snippets_test.go @@ -0,0 +1,285 @@ +package apify_test + +// This file verifies the documentation requirement that "each in-documentation code snippet +// has to be a valid, runnable and properly formatted code." Go has no built-in doctest +// mechanism for Markdown (unlike Rust's `cargo test --doc`), so this test plays that role: it +// extracts every fenced ```go code block from the README and the docs/ pages, then for each +// snippet +// +// - checks it is gofmt-formatted (proper formatting), and +// - compiles it with `go build` (valid, runnable code). +// +// A snippet that begins with `package ` is treated as a complete program and built as-is; +// every other snippet is a fragment that assumes a configured `client` and a `ctx`, so it is +// wrapped in a function with those (and a few other doc-wide placeholders) predeclared before +// being compiled. The test is offline — it never executes the snippets against the API — so it +// runs without APIFY_TOKEN and is exercised by the standalone "Test examples" CI step. + +import ( + "fmt" + "go/format" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// docFiles are the documentation files whose ```go snippets must compile and be formatted, +// relative to the module root (the parent of this tests/ directory). +var docFiles = []string{ + "README.md", + "docs/README.md", + "docs/actors.md", + "docs/builds.md", + "docs/runs.md", + "docs/tasks.md", + "docs/storages.md", + "docs/schedules.md", + "docs/webhooks.md", + "docs/misc.md", +} + +// goFenceRe matches a fenced ```go ... ``` block, capturing the snippet body. +var goFenceRe = regexp.MustCompile("(?s)```go\\n(.*?)\\n```") + +// topLevelShortVarRe matches the left-hand side of a `:=` short variable declaration that +// sits at the outermost level of a fragment (column 0 — fragments are authored unindented, so +// nested declarations carry leading tabs). We emit a blank assignment for each such name to +// avoid "declared and not used" errors when a doc fragment introduces a variable only to +// illustrate a call. Nested declarations are intentionally skipped: their scope is the inner +// block, so a function-level discard would not even refer to them. +var topLevelShortVarRe = regexp.MustCompile(`(?m)^([a-zA-Z_][\w]*(?:\s*,\s*[a-zA-Z_][\w]*)*)\s*:=`) + +// declaresName reports whether a fragment declares a given identifier itself (via `:=`, `=`, +// or `var`), in which case the wrapper must not also predeclare it. +func declaresName(body, name string) bool { + // `name :=` / `name =` (also matches the identifier as part of a multi-name LHS). + assign := regexp.MustCompile(`(?m)(^|[\s,(])` + regexp.QuoteMeta(name) + `\b\s*:?=`) + if assign.MatchString(body) { + return true + } + // `var name ...` + return regexp.MustCompile(`(?m)\bvar\s+` + regexp.QuoteMeta(name) + `\b`).MatchString(body) +} + +type snippet struct { + file string + body string +} + +// collectSnippets extracts all ```go fenced blocks from the documentation files. +func collectSnippets(t *testing.T, root string) []snippet { + t.Helper() + var out []snippet + for _, rel := range docFiles { + data, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + for _, m := range goFenceRe.FindAllStringSubmatch(string(data), -1) { + out = append(out, snippet{file: rel, body: m[1]}) + } + } + if len(out) == 0 { + t.Fatal("no ```go snippets found in documentation; extractor or docs changed") + } + return out +} + +// assembleProgram turns a doc snippet into a self-contained, compilable Go source file. +// +// Full-program snippets (those that declare their own package) are used verbatim. Fragments +// are wrapped in a synthetic main package: the snippet runs inside an anonymous function with +// the doc-wide placeholders (client, ctx, and a few common IDs) predeclared, and every +// short-variable the fragment introduces is discarded with a trailing blank assignment so the +// fragment compiles without "declared and not used" noise. +func assembleProgram(s snippet) string { + if strings.HasPrefix(strings.TrimSpace(s.body), "package ") { + return s.body + } + + declared := map[string]bool{} + var discards strings.Builder + for _, m := range topLevelShortVarRe.FindAllStringSubmatch(s.body, -1) { + for _, name := range strings.Split(m[1], ",") { + name = strings.TrimSpace(name) + if name == "" || name == "_" || declared[name] { + continue + } + declared[name] = true + discards.WriteString("\t_ = " + name + "\n") + } + } + + var b strings.Builder + b.WriteString("package main\n\n") + b.WriteString("import (\n") + b.WriteString("\t\"context\"\n") + b.WriteString("\t\"fmt\"\n") + b.WriteString("\t\"io\"\n") + b.WriteString("\t\"log\"\n") + b.WriteString("\t\"net/http\"\n") + b.WriteString("\t\"os\"\n") + b.WriteString("\t\"time\"\n\n") + b.WriteString("\tapify \"github.com/apify/apify-client-go\"\n") + b.WriteString(")\n\n") + // Reference the imports that a given fragment may not use, so the wrapper never fails on + // unused imports regardless of which snippet it carries. + b.WriteString("var (\n") + b.WriteString("\t_ = fmt.Sprint\n") + b.WriteString("\t_ = io.EOF\n") + b.WriteString("\t_ = log.Print\n") + b.WriteString("\t_ = http.MethodGet\n") + b.WriteString("\t_ = os.Stdout\n") + b.WriteString("\t_ = time.Second\n") + b.WriteString(")\n\n") + b.WriteString("func snippet() {\n") + // Doc-wide placeholders documented in docs/README.md: snippets assume a configured client + // and a context, plus a handful of resource IDs used by the per-resource pages. Each is + // predeclared only when the fragment does not declare it itself, so a snippet that opens + // with `client := apify.NewClient(...)` is not shadowed. + placeholders := []struct{ name, decl string }{ + {"client", "var client *apify.ApifyClient"}, + {"ctx", "var ctx context.Context"}, + {"actorID", "var actorID string"}, + {"runID", "var runID string"}, + {"buildID", "var buildID string"}, + } + for _, p := range placeholders { + if declaresName(s.body, p.name) { + continue + } + b.WriteString("\t" + p.decl + "\n") + b.WriteString("\t_ = " + p.name + "\n") + } + b.WriteString("\n") + b.WriteString(s.body) + b.WriteString("\n") + b.WriteString(discards.String()) + b.WriteString("}\n\n") + b.WriteString("func main() { snippet() }\n") + return b.String() +} + +func TestDocSnippetsFormatted(t *testing.T) { + root := ".." + for _, s := range collectSnippets(t, root) { + // gofmt is whitespace-sensitive; the snippet body in the doc must already be the + // canonical gofmt output. We compare against gofmt of the body itself (parsed as a + // fragment via a minimal wrapper) to keep the check local to the snippet. + formatted, err := gofmtFragment(s.body) + if err != nil { + t.Errorf("%s: snippet does not parse: %v\n%s", s.file, err, s.body) + continue + } + if formatted != s.body { + t.Errorf("%s: snippet is not gofmt-formatted.\n--- have ---\n%s\n--- want ---\n%s", + s.file, s.body, formatted) + } + } +} + +func TestDocSnippetsCompile(t *testing.T) { + root := ".." + dir := t.TempDir() + + // A throwaway module that depends on the local client via a replace directive, so the + // snippets compile against the exact source in this checkout. + abs, err := filepath.Abs(root) + if err != nil { + t.Fatalf("abs root: %v", err) + } + gomod := "module docsnippets\n\ngo 1.23\n\nrequire github.com/apify/apify-client-go v0.0.0\n\nreplace github.com/apify/apify-client-go => " + abs + "\n" + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + + snips := collectSnippets(t, root) + for i, s := range snips { + prog := assembleProgram(s) + sub := filepath.Join(dir, fmt.Sprintf("snip%03d", i)) + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(sub, "main.go"), []byte(prog), 0o644); err != nil { + t.Fatalf("write snippet program: %v", err) + } + } + + // Build every assembled snippet program in one pass. + cmd := exec.Command("go", "build", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("doc snippets failed to compile:\n%s", out) + } +} + +// gofmtFragment formats a snippet body using the same rules gofmt applies to a file. Fragments +// (which are not valid standalone files) are wrapped in a function, formatted, then unwrapped, +// so the comparison reflects exactly what an author would get from `gofmt` on the snippet. +func gofmtFragment(body string) (string, error) { + trimmed := strings.TrimSpace(body) + if strings.HasPrefix(trimmed, "package ") { + out, err := gofmtSource(body) + return strings.TrimRight(out, "\n"), err + } + + wrapped := "package p\n\nfunc _f() {\n" + indent(body) + "\n}\n" + out, err := gofmtSource(wrapped) + if err != nil { + return "", err + } + return dedentFuncBody(out), nil +} + +// gofmtSource formats a complete source file with the same engine gofmt uses +// (go/format.Source), in-process — no subprocess and no separate parse step (Source reports a +// parse error itself). +func gofmtSource(src string) (string, error) { + out, err := format.Source([]byte(src)) + return string(out), err +} + +// indent adds one tab to every non-empty line so a fragment sits correctly inside a function +// body before formatting. +func indent(s string) string { + lines := strings.Split(s, "\n") + for i, ln := range lines { + if strings.TrimSpace(ln) != "" { + lines[i] = "\t" + ln + } + } + return strings.Join(lines, "\n") +} + +// dedentFuncBody extracts the body of the synthetic `_f` function produced by gofmtFragment and +// removes the single leading tab gofmt added, recovering the canonical formatting of the +// original fragment. +func dedentFuncBody(formatted string) string { + lines := strings.Split(formatted, "\n") + var body []string + in := false + for _, ln := range lines { + if !in { + if strings.HasPrefix(ln, "func _f() {") { + in = true + } + continue + } + if ln == "}" { + break + } + body = append(body, strings.TrimPrefix(ln, "\t")) + } + // Drop leading/trailing blank lines introduced by the wrapper. + for len(body) > 0 && strings.TrimSpace(body[0]) == "" { + body = body[1:] + } + for len(body) > 0 && strings.TrimSpace(body[len(body)-1]) == "" { + body = body[:len(body)-1] + } + return strings.Join(body, "\n") +} diff --git a/version.go b/version.go index 2b307ea..59a9a69 100644 --- a/version.go +++ b/version.go @@ -4,7 +4,7 @@ package apify // // It follows Semantic Versioning (https://semver.org/). Changes to the public // interface (other than additive ones) are considered breaking changes. -const CLIENT_VERSION = "0.2.0" +const CLIENT_VERSION = "0.2.1" // API_SPEC_VERSION is the version of the Apify OpenAPI specification that this // client was generated and verified against.