Skip to content

DI extension ​

Since 4.0.0

Tomaj\NetteApi\DI\NetteApiExtension is new in 4.0.0. The services: setup from Getting started keeps working, the extension is optional.

The extension registers everything the API needs and reads the endpoints from one api: section. Instead of a list of addApi() calls with long class names you write what the endpoint is:

yaml
extensions:
	api: Tomaj\NetteApi\DI\NetteApiExtension

api:
	endpoints:
		- {method: GET, version: 1, package: hello, handler: App\Api\HelloHandler}

That is a complete API. GET /api/v1/hello calls App\Api\HelloHandler from Getting started. You still need the API route from step 2 of Getting started. The application: mapping for the Api module is added by the extension; if you already have one, yours is kept.

Every mistake is found when Nette compiles the container, not when a client calls the endpoint: an unknown handler class, a typo in an authorization name, the same endpoint twice. The error message says which endpoint and what to change.

What the extension registers ​

ServiceClassChange it with
api.deciderApiDeciderapi.endpoints
api.presenterApiPresenterapi.cors, api.logging
api.outputConfiguratorEnvConfiguratorapi.outputConfigurator
api.errorHandlerDefaultErrorHandlerapi.errors
api.ipDetectorIpDetectorapi.ipDetector
api.loggeronly when you set itapi.logger

With an empty api: section you get no endpoints, no CORS headers, the 3.x error format and error details unless APP_ENV=production. That is the services: setup from Getting started, except that Getting started uses DebuggerConfigurator (set outputConfigurator: debugger to get it) and the presenter there sends Access-Control-Allow-Origin: * (set cors: {mode: '*'} to get it).

Your own services win

If the services: section already has an ApiDecider, ApiPresenter, error handler, output configurator or IP detector, the extension uses yours and adds its configuration to it. So you can switch to the extension one endpoint at a time, see Moving from services.

Endpoints ​

Each item of api.endpoints is one endpoint:

KeyRequiredMeaning
methodyesHTTP method, GET, post, ... (case does not matter)
versionyes1, 2, '2.1' without the v; the URL is /api/v{version}/.... Write a version with a dot in quotes, neon reads 2.10 as the number 2.1
packageyesthe first part of the URL after the version, without a slash
actionnothe second part, /api/v1/users/detail has the action detail, without a slash
handleryesthe handler, see below
authorizationnowho may call it, see Authorization
rateLimitnohow often, see Authorization
yaml
api:
	endpoints:
		# GET /api/v1/users
		- {method: GET, version: 1, package: users, handler: App\Api\UsersListHandler}
		# GET /api/v1/users/detail?id=5
		- {method: GET, version: 1, package: users, action: detail, handler: App\Api\UserDetailHandler}
		# POST /api/v1/users
		- {method: POST, version: 1, package: users, handler: App\Api\UserCreateHandler}
		# GET /api/v2.1/users
		- {method: GET, version: '2.1', package: users, handler: App\Api\V2\UsersListHandler}

Endpoints are registered in this order, which is also the order in the endpoint listing and in the web console.

The same method, version, package and action twice stops the compilation:

Endpoint GET /v1/users is defined twice: in api.endpoints[0] and in api.endpoints[3]. Remove one of them, or change the method, version, package or action of one so the URLs differ.

The handler ​

You can write the handler in three ways:

yaml
api:
	endpoints:
		# 1. a class: the extension creates it with autowiring
		- {method: GET, version: 1, package: users, handler: App\Api\UsersListHandler}

		# 2. a class with arguments: a statement, like in services:
		- {method: GET, version: 1, package: export, handler: App\Api\ExportHandler(%tempDir%/exports)}

		# 3. a service from the services: section
		- {method: GET, version: 1, package: reports, handler: @reportsHandler}

services:
	reportsHandler: App\Api\ReportsHandler(%tempDir%/reports)
  • A class becomes a service, so its constructor gets other services by autowiring, the same as any other service. One class used by more endpoints is one service. When the services: section already has an autowired service of exactly that class, the extension uses it.
  • A statement Class(arguments) is a new service only for this endpoint.
  • @name is the service with that name. Write it without quotes, '@name' in quotes is a plain string in neon.

Handlers are created only when their endpoint is called, so an API with a hundred endpoints does not create a hundred objects on every request.

Authorization and rate limits ​

Name the authorizations and rate limits once, then use the names in endpoints:

yaml
parameters:
	adminPassword: change-me
	apiTokens:
		secret-token: '*'

api:
	authorizations:
		token: Tomaj\NetteApi\Authorization\BearerTokenAuthorization(Tomaj\NetteApi\Misc\StaticTokenRepository(%apiTokens%))
		admin: Tomaj\NetteApi\Authorization\BasicAuthentication([admin: %adminPassword%])
	rateLimits:
		strict: App\FileRateLimit(storageDir: %tempDir%, limit: 2)

	endpoints:
		- {method: GET, version: 1, package: users, handler: App\Api\UsersListHandler, authorization: token}
		- {method: DELETE, version: 1, package: users, handler: App\Api\UserDeleteHandler, authorization: admin, rateLimit: strict}

authorization and rateLimit of an endpoint accept:

ValueMeaning
a name from api.authorizations / api.rateLimitsthat object, shared by all endpoints that use the name
@servicea service from services:
a class or Class(arguments)a new object for this endpoint
falsenone, even when there is a default
nothingthe default from api.defaults, else no authorization and no rate limit

Defaults ​

Most APIs protect almost every endpoint. Set the authorization and the rate limit once in api.defaults for all endpoints, then change them only where an endpoint needs something else:

yaml
api:
	authorizations:
		token: Tomaj\NetteApi\Authorization\BearerTokenAuthorization(Tomaj\NetteApi\Misc\StaticTokenRepository(%apiTokens%))
		admin: Tomaj\NetteApi\Authorization\BasicAuthentication([admin: %adminPassword%])
	rateLimits:
		normal: App\Api\MyRateLimit(limit: 100)
		strict: App\Api\MyRateLimit(limit: 5)
	defaults:
		authorization: token
		rateLimit: normal
	endpoints:
		# token and normal, from the defaults
		- {method: GET, version: 1, package: users, handler: App\Api\UsersListHandler}
		# admin instead of token, the rate limit stays normal
		- {method: DELETE, version: 1, package: users, handler: App\Api\UserDeleteHandler, authorization: admin}
		# public and with the strict rate limit
		- {method: POST, version: 1, package: login, handler: App\Api\LoginHandler, authorization: false, rateLimit: strict}
		# public and without a rate limit
		- {method: GET, version: 1, package: hello, handler: App\Api\HelloHandler, authorization: false, rateLimit: false}

Each of the two keys is decided on its own: an endpoint without the key uses the default, a name or a class replaces it, false turns it off. api.defaults itself does not take false, leave the key out when you want no default. Use a name from api.authorizations / api.rateLimits as the default, so all endpoints share one object; a class name there creates a new object for every endpoint.

A class of the wrong type stops the compilation too, for example a handler class used as an authorization:

Class 'App\Api\HelloHandler' in api.authorizations.token must implement Tomaj\NetteApi\Authorization\ApiAuthorizationInterface, but it does not. Implement the interface in the class, or use another class.

A typo in a name stops the compilation and lists the names you defined:

Unknown authorization 'tokne' in the authorization of endpoint GET /v1/users (api.endpoints[0]), it is not a name from api.authorizations nor an existing class. Did you mean 'token'? Names defined in api.authorizations: 'token'. Or use @serviceName, a class implementing Tomaj\NetteApi\Authorization\ApiAuthorizationInterface, or a statement like App\MyClass(arguments). To add the name, write api: {authorizations: {tokne: App\Api\MyAuthorization}}.

Read Authorization and Rate limiting for the classes you can use.

Errors ​

api.errors.format picks the error format:

yaml
api:
	errors:
		# default: {"status":"error","message":"..."}, the 3.x format
		# problemDetails: RFC 9457 application/problem+json for every error
		# negotiating: problem details only for clients that send Accept: application/problem+json
		format: problemDetails
		typeBaseUri: https://api.example.com/problems/

typeBaseUri is the start of the problem type (https://api.example.com/problems/not-found), see Problem Details. It works only with problemDetails and negotiating.

Your own error handler goes to handler instead:

yaml
api:
	errors:
		handler: App\Api\MyErrorHandler

Output configurator ​

api.outputConfigurator decides whether error responses show the exception message and whether outputs are validated, see Outputs:

ValueClass
nothing, or envEnvConfigurator: details unless APP_ENV=production
debuggerDebuggerConfigurator: details when Tracy is in development mode
queryQueryConfigurator: details with ?error_detail=1
a class, Class(arguments) or @serviceyour own ConfiguratorInterface
yaml
api:
	outputConfigurator: Tomaj\NetteApi\Output\Configurator\EnvConfigurator('APP_ENV', 'production')

CORS ​

yaml
api:
	cors:
		# the origins that may call the API from a browser
		allowedOrigins: [https://app.example.com, 'http://localhost:3000']
		# send Access-Control-Allow-Credentials: true to them (cookies)
		allowCredentials: true
		# answer OPTIONS for every endpoint
		preflight: true
KeySame asDefault
modesetCorsHeader(): '*', 'off', 'reflect-unsafe' (unsafe, the old 'auto') or one origin ('auto' fails the compilation with the allowlist to use instead)'off'
allowedOriginssetCorsAllowedOrigins()not set
originValidatorsetCorsOriginValidator(): a class, @service or statement implementing CorsOriginValidatorInterface (dynamic origins), cannot be combined with mode or allowedOriginsnot set
allowCredentialsthe second argument of setCorsAllowedOrigins() / setCorsOriginValidator(), needs allowedOrigins or originValidatorfalse
preflightenableGlobalPreflight(): true, or a map of headers for CorsPreflightHandlerfalse
yaml
api:
	cors:
		mode: 'off'
		preflight:
			Access-Control-Allow-Headers: [Content-Type, Authorization]
			Access-Control-Max-Age: '600'

Without mode the extension sends no CORS headers, unlike ApiPresenter on its own, which sends *. A browser app on another origin needs allowedOrigins or mode. If you register your own ApiPresenter with setCorsHeader() in services:, the extension keeps that value. The CORS page explains what each mode sends.

Logging ​

yaml
api:
	logger: App\Api\DatabaseLogger
	logging:
		maskHeaders:                 # true (default) | false | a list | a map
			add: [X-Session]
			remove: [Set-Cookie]
			patterns: {add: [x-signature-*], remove: ['*key*']}
		maskQueryParams:             # true (default) | false | a list | a map
			add: [session_id]
		masking: partial             # full (default) | partial | hash | omit | your masker

Secret headers and query parameters are masked by default, also without the logging: section. Each key is optional:

KeyValues
maskHeaderstrue (default), false (off, with the patterns and the authorization names), a list that replaces the default names, or {names, add, remove, patterns}; patterns is true, false, a list or {add, remove}
maskQueryParamstrue (default), false, a list that replaces the default names, or {names, add, remove}
maskingfull (default), partial, hash, omit, or your LogValueMaskerInterface as Class, Class(args) or @service
hashKeythe HMAC key for masking: hash, for example %env.API_LOG_HASH_KEY%

A map applies names, then add, then remove. The names used by your HeaderApiKeyAuthentication, QueryApiKeyAuthentication and CookieApiKeyAuthentication are masked too. Do not change the same thing in the setup: of your ApiPresenter service as well; the extension would replace it, so compiling fails. See Logging for every form, the defaults, the modes and the logged output.

IP detector ​

yaml
api:
	ipDetector: App\Api\ProxyIpDetector

The default IpDetector reads REMOTE_ADDR.

Configuration reference ​

All keys with their defaults:

yaml
api:
	outputConfigurator: null   # env | debugger | query | Class | Class(args) | @service
	ipDetector: null           # Class | Class(args) | @service, default IpDetector
	logger: null               # Class | Class(args) | @service, default no logger
	errors:
		format: null           # default | problemDetails | negotiating
		typeBaseUri: null      # with problemDetails or negotiating
		handler: null          # Class | Class(args) | @service, instead of format
	cors:
		mode: null             # '*' | 'off' | 'reflect-unsafe' (unsafe) | an origin, default 'off'
		allowedOrigins: null   # list of origins
		originValidator: null  # Class | Class(args) | @service implementing CorsOriginValidatorInterface
		allowCredentials: false
		preflight: false       # true | map of headers
	logging:
		maskHeaders: true      # false | list of headers | {names, add, remove, patterns}
		maskQueryParams: true  # false | list of parameters | {names, add, remove}
		masking: full          # partial | hash | omit | Class | Class(args) | @service
		hashKey: null          # string, only with masking: hash
	authorizations: []         # name: Class | Class(args) | @service
	rateLimits: []             # name: Class | Class(args) | @service
	defaults:
		authorization: null    # a name | Class | Class(args) | @service
		rateLimit: null        # a name | Class | Class(args) | @service
	endpoints: []              # {method, version, package, action, handler, authorization, rateLimit}

An unknown key is an error, so a typo such as cors: {origins: ...} does not silently do nothing.

Configuration errors ​

Every mistake in the api: section stops the container compilation with a Nette\DI\InvalidConfigurationException. The message names the neon key, says what is wrong and how to fix it, usually with an example. The most common ones:

MistakeExampleThe error says
Unknown keycors: {origins: [...]}, pakage: usersUnexpected item 'api › cors › origins', a "did you mean" and all allowed keys of that section
Wrong typeendpoints: 5, maskHeaders: 5what the key expects and an example, e.g. Use a list of endpoints, for example endpoints: [{...}]
Missing endpoint key- {method: GET, version: 1}The mandatory item ... is missing. Every endpoint needs method, version, package and handler
Unknown valueerrors: {format: problemdetails}Did you mean 'problemDetails'? and the allowed values
Class does not existlogger: App\Nope, logger: App\Nope(1)the class, the key, and to check the spelling and the composer autoload
Class of the wrong typelogger: stdClasswhich interface the class must implement
Service does not exist or has the wrong typelogger: @nopeto register the service, or to use a class name
Quoted referencehandler: '@users'to remove the quotes
Unknown authorization or rate limit nameauthorization: bererDid you mean 'bearer'? and the defined names
Invalid authorization or rate limit nameauthorizations: {my-auth: ...}that a name has only letters, digits and underscores
Typo in the HTTP methodmethod: GETTDid you mean 'GET'? and the HTTP methods
Invalid version, package or actionversion: v1, package: users/list, package: 'u x'which characters are allowed, with an example
Duplicate endpointtwo GET /v1/usersboth places
errors.handler with format or typeBaseUrierrors: {handler: ..., format: problemDetails}to keep only one of them
typeBaseUri without a problem details format, or not an absolute URItypeBaseUri: example.com/errorsto add format: problemDetails, or to use e.g. https://example.com/errors/
Invalid CORS settingsmode: '', mode: https://a.com/path, allowedOrigins: [], allowCredentials without allowedOrigins or originValidator, originValidator together with mode or allowedOrigins, a validator without CorsOriginValidatorInterfacethe valid modes or origins, what to remove or which interface to implement, with an example
Empty or invalid header listpreflight: {}, maskHeaders: [], maskHeaders: ['X Secret']to use true for the defaults, false to turn the masking off, or to write only header names
Masking entry that is not there or is malformedmaskHeaders: {remove: [Set-Cokie]}, maskHeaders: {remove: [X-Auth-Token]}, maskQueryParams: {add: ['token[]']}Did you mean 'Set-Cookie'? and the current list, the pattern that masks the header, or to write only the name
Same entry in add and remove, or an empty mapmaskHeaders: {add: [X-A], remove: [X-A]}, maskHeaders: {}, patterns: {}to keep it in one of them, or to use true or false
Invalid header patternpatterns: [token], patterns: {add: ['*']}, patterns: ['x auth*']to use * or add it as a name, that it would match every header, or which characters are allowed
Unknown masking mode or wrong hashKeymasking: hashed, hashKey with masking: full, hashKey: ''Did you mean 'hash'? and the modes, to use masking: hash, or a long random key
logging: key and ApiPresenter setup change the same thingmaskHeaders: false and - addMaskedLogHeader(X-A) in services:that the extension would replace the setup, and how to move the call into the key
Discovery path is missing or is a filepaths: [%appDir%/Api/UsersHandler.php]to use a directory
discovery.namespace without paths, or matching no classdiscovery: {namespace: App}to add the paths, or the namespaces that were found
Option and your own service of the same typelogger: ... and a logger in services:to remove one of them

Moving from services ​

The extension uses the services you already have. Add it, then move one endpoint at a time from addApi() to api.endpoints:

yaml
extensions:
	api: Tomaj\NetteApi\DI\NetteApiExtension

services:
	apiPresenter:
		factory: Tomaj\NetteApi\Presenters\ApiPresenter
	- Tomaj\NetteApi\Output\Configurator\DebuggerConfigurator
	- Tomaj\NetteApi\Error\DefaultErrorHandler
	apiDecider:
		factory: Tomaj\NetteApi\ApiDecider
		setup:
			# not moved yet
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'old'), App\Api\OldHandler(), Tomaj\NetteApi\Authorization\NoAuthorization())

api:
	endpoints:
		# moved
		- {method: GET, version: 1, package: hello, handler: App\Api\HelloHandler}

Both endpoints end up in your apiDecider. When all of them are moved, delete the services: lines.

CORS changes when you add the extension

Your apiPresenter sent Access-Control-Allow-Origin: * because that is its default. With the extension and no api: cors, it sends no CORS headers. If browsers on other origins call the API, add cors: allowedOrigins: [...], or cors: mode: '*' to keep what you had. A setCorsHeader() in your presenter's setup: is kept.

Setting an option and having the same service in services: is a conflict, because the extension would not know which one you mean:

api.errors is set, but service 'errorHandler' of type Tomaj\NetteApi\Error\ErrorHandlerInterface is registered in the services section too, and the api extension does not know which one to use. Remove api.errors, or remove the service 'errorHandler'.

Design decisions ​

Deliberate limits of the extension. Each one has a backlog issue with the pros, the cons and when it would be added.

No short names for rate-limit strategies yet ​

A rate limit is a service, a class or a statement such as strict: App\MyRateLimit(60).

  • Why: the only built-in strategy is NoRateLimit. Short names like slidingWindow(60, 60) would point to classes that do not exist yet.
  • Why not now: the names and arguments depend on the design in #195; aliases added earlier could not change later without a BC break.
  • When it would make sense: after #195 is merged. See #229.

No openapi: option ​

The OpenAPI endpoint is registered like any other one, in api: endpoints: with the arguments of the handler.

  • Why: the handler has several options (initData with info, servers and security, allowedEndpoints, specBuilder). Copying them into the extension adds a second way to do the same thing.
  • Why not: more schema and docs to keep in sync, and it is less obvious where the endpoint comes from.
  • When it would make sense: most projects copy the same block, or the extension can fill in useful defaults, such as security schemes from api: authorizations:. See #230.

Decisions about attribute endpoints are on their page.

FAQ ​

Do I have to use the extension? ​

No. The services: configuration from 3.x works without any change. The extension is a shorter way to write the same thing.

Why does my endpoint answer 404? ​

Check the API route from Getting started. The extension registers the presenter and maps the Api module to it, but the route is yours. If your application: mapping has its own Api entry, the extension keeps it, so it must point to the namespace of the ApiPresenter you use (Tomaj\NetteApi\Presenters\*Presenter unless you have your own subclass).

Why does the browser say the CORS header is missing? ​

With the extension, CORS is off until you configure it. Add the origins of your frontend to api: cors: allowedOrigins, or set mode, see CORS.

Why does handler: '@myHandler' say the service does not exist? ​

In neon a quoted '@myHandler' is a plain string that starts with @. Write @myHandler without quotes.

Can a handler get other services in its constructor? ​

Yes. A handler written as a class is created with autowiring, like any service. See the UserRepository in the example.

How do I make one endpoint public when there is a default authorization? ​

Set authorization: false on that endpoint.

Can I set the authorization and the rate limit for all endpoints and change them for one? ​

Yes. Put them in api.defaults, then write authorization or rateLimit only on the endpoints that differ: a name to use another one, false for none. See Defaults.

How do I see what the extension generated? ​

Open the compiled container in temp/cache/nette.configurator/. The createServiceApi__decider() method has an addApi() call for each endpoint.

Can another extension add endpoints? ​

Yes, call addEndpoint() on the API extension in its loadConfiguration(). The duplicate check covers these endpoints too:

php
use Nette\DI\CompilerExtension;
use Tomaj\NetteApi\DI\EndpointDefinition;
use Tomaj\NetteApi\DI\NetteApiExtension;

final class ReportsExtension extends CompilerExtension
{
    public function loadConfiguration(): void
    {
        foreach ($this->compiler->getExtensions(NetteApiExtension::class) as $api) {
            $api->addEndpoint(new EndpointDefinition(
                method: 'GET',
                version: 1,
                package: 'reports',
                action: null,
                handler: \App\Api\ReportsHandler::class,
                authorization: null, // null: api.defaults, false: none
                rateLimit: null,
                source: 'ReportsExtension', // shown in error messages
            ));
        }
    }
}

Register it after the API extension in extensions:.

Where is a complete project? ​

In examples/di-extension. Its e2e.sh test runs in CI.

Can I declare endpoints on the handler classes instead? ​

Yes, with #[Get], #[Post] and the other attributes. The extension reads them at compile time and checks them together with endpoints. See Attribute endpoints.

Released under the MIT License.