From 24dea5ca977bc6ecbdbafc50696d1305d40b8978 Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Tue, 23 Aug 2022 11:34:15 +0530 Subject: [PATCH] docs: Re-Structure Features & Examples --- README.md | 407 ++-------------------- docs/default_get_doc_get_list.md | 39 +++ docs/examples/add_document_count_query.md | 25 ++ docs/examples/add_hello_world_query.md | 43 +++ docs/examples/add_new_custom_field.md | 10 + docs/examples/add_new_doctype.md | 19 + docs/examples/add_new_mutation.md | 56 +++ docs/extending_schema.md | 21 ++ docs/file_uploads.md | 47 +++ docs/link_fields_nested.md | 56 +++ docs/middleware.md | 23 ++ docs/restrict_depth.md | 5 + 12 files changed, 375 insertions(+), 376 deletions(-) create mode 100644 docs/default_get_doc_get_list.md create mode 100644 docs/examples/add_document_count_query.md create mode 100644 docs/examples/add_hello_world_query.md create mode 100644 docs/examples/add_new_custom_field.md create mode 100644 docs/examples/add_new_doctype.md create mode 100644 docs/examples/add_new_mutation.md create mode 100644 docs/extending_schema.md create mode 100644 docs/file_uploads.md create mode 100644 docs/link_fields_nested.md create mode 100644 docs/middleware.md create mode 100644 docs/restrict_depth.md diff --git a/README.md b/README.md index 9c89c69..5fbd000 100644 --- a/README.md +++ b/README.md @@ -1,394 +1,49 @@ -## Frappe Graphql +# Frappe Graphql -GraphQL API Layer for Frappe Framework +GraphQL API Layer for Frappe Framework based on [graphql-core](https://github.com/graphql-python/graphql-core) -#### License +## Getting Started -MIT +Generate the SDLs first -## Instructions -Generate the sdls first -``` +```bash $ bench --site test_site graphql generate_sdl ``` + and start making your graphql requests against: + ``` /api/method/graphql ``` # Features -## Getting single Document and getting a filtered doctype list -You can get a single document by its name using `` query. -For sort by fields, only those fields that are search_indexed / unique can be used. NAME, CREATION & MODIFIED can also be used -
-Example - -Query -``` -{ - User(name: "Administrator") { - name, - email - } -} -``` -You can get a list of documents by querying ``. You can also pass in filters and sorting details as arguments: -```graphql -{ - Users(filter: [["name", "like", "%a%"]], sortBy: { field: NAME, direction: ASC }) { - totalCount, - pageInfo { - hasNextPage, - hasPreviousPage, - startCursor, - endCursor - }, - edges { - cursor, - node { - name, - first_name - } - } - } - } -} -``` -
-
- -## Access Field Linked Documents in nested queries -All Link fields return respective doc. Add `__name` suffix to the link field name to get the link name. -
-Example - -Query -```gql -{ - ToDo (limit_page_length: 1) { - name, - priority, - description, - assigned_by__name, - assigned_by { - full_name, - roles { - role__name, - role { - name, - creation - } - } - } - } -} -``` -Result -```json -{ - "data": { - "ToDo": [ - { - "name": "ae6f39845b", - "priority": "Low", - "description": "

Do this

", - "assigned_by__name": "Administrator", - "assigned_by": { - "full_name": "Administrator", - "roles": [ - { - "role__name": "System Manager", - "role": { - "name": "System Manager", - "creation": "2021-02-02 08:34:42.170306", - } - } - ] - } - } - ... - ] - } -} -``` -
-
- -## Restrict Query/Mutation depth - -Query/Mutation is restricted by default to 10. - -You can change the depth limit by setting the site config `frappe_graphql_depth_limit: 15`. - -
- -## Subscriptions -Get notified instantly of the updates via existing frappe's SocketIO. Please read more on the implementation details [here](./docs/subscriptions.md) -
- -## File Uploads -File uploads can be done following the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). `uploadFile` mutation is included implementing the same - -
-Example - -Query -```http -POST /api/method/graphql HTTP/1.1 -Host: test_site:8000 -Accept: application/json -Cookie: full_name=Administrator; sid=; system_user=yes; user_id=Administrator; user_image= -Content-Length: 553 -Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW - -----WebKitFormBoundary7MA4YWxkTrZu0gW -Content-Disposition: form-data; name="operations" - -{ - "query": "mutation uploadFile($file: Upload!) { uploadFile(file: $file) { name, file_url } }", - "variables": { - "file": null - } -} -----WebKitFormBoundary7MA4YWxkTrZu0gW -Content-Disposition: form-data; name="map" - -{ "0": ["variables.file"] } -----WebKitFormBoundary7MA4YWxkTrZu0gW -Content-Disposition: form-data; name="0"; filename="/D:/faztp12/Pictures/BingImageOfTheDay_20190715.jpg" -Content-Type: image/jpeg - -(data) -----WebKitFormBoundary7MA4YWxkTrZu0gW -``` - -Response -```json -{ - "data": { - "uploadFile": { - "name": "ce36b2e222", - "file_url": "/files/BingImageOfTheDay_20190715.jpg" - } - } -} -``` -
- -
- -## RolePermission integration -Data is returned based on Read access to the resource -
- -## Standard Mutations: set_value , save_doc & delete_doc -- [SET_VALUE Mutation](./docs/SET_VALUE.md) -- [SAVE_DOC Mutation](./docs/SAVE_DOC.md) -- [DELETE_DOC Mutation](./docs/DELETE_DOC.md) -
- -## Pagination -Cursor based pagination is implemented. You can read more about it here: [Cursor Based Pagination](./docs/cursor_pagination.md) -
- -## Support Extensions via Hooks -You can extend the SDLs with additional query / mutations / subscriptions. Examples are provided for a specific set of Scenarios. Please read [GraphQL Spec](http://spec.graphql.org/June2018/#sec-Object-Extensions) regarding Extending types. There are mainly two hooks introduced: -- `graphql_sdl_dir` -Specify a list of directories containing `.graphql` files relative to the app's root directory. -eg: -```py -# hooks.py -graphql_sdl_dir = [ - "./your-app/your-app/generated/sdl/dir1", - "./your-app/your-app/generated/sdl/dir2", -] -``` -The above will look for graphql files in `your-bench/apps/your-app/your-app/generated/sdl/dir1` & `./dir2` folders. - - -- `graphql_schema_processors` -You can pass in a list of cmd that gets executed on schema creation. You are given `GraphQLSchema` object (please refer [graphql-core](https://github.com/graphql-python/graphql-core)) as the only parameter. You can modify it or extend it as per your requirements. -This is a good place to attach the resolvers for the custom SDLs defined via `graphql_sdl_dir` -
- -## Support Extension of Middlewares via hooks -We can add graphql middlewares by adding the path through hooks. -Please note the return type and arguments being passed to your custom middleware. -```py -# hooks.py -graphql_middlewares = ["frappe_graphql.utils.middlewares.disable_introspection_queries.disable_introspection_queries"] -``` -```py -def disable_introspection_queries(next_resolver, obj, info: GraphQLResolveInfo, **kwargs): - # https://github.com/jstacoder/graphene-disable-introspection-middleware - if is_introspection_disabled() and info.field_name.lower() in ['__schema', '__introspection']: - raise IntrospectionDisabled(frappe._("Introspection is disabled")) - - return next_resolver(obj, info, **kwargs) - - -def is_introspection_disabled(): - return not cint(frappe.local.conf.get("developer_mode")) and \ - not cint(frappe.local.conf.get("enable_introspection_in_production")) -``` -
-## Introspection in Production -Introspection is disabled by default in production mode. You can enable by setting the site config `enable_introspection_in_production: 1`. - -
- -## Helper wrappers -- Exception Handling in Resolvers. We provide a utility resolver wrapper function which could be used to return your expected exceptions as user errors. You can read more about it [here](./docs/resolvers_and_exceptions.md). -- Role Permissions for Resolver. We provide another utility resolver wrapper function which could be used to verify the logged in User has the roles specified. You can read more about it [here](./docs/resolver_role_permissions.md) -
+- [Default GetDoc & GetList](./docs/default_get_doc_get_list.md) +- [Get Nested Link Field Documents](./docs/link_fields_nested.md) +- [Cursor Based Pagination](./docs/cursor_pagination.md) +- [Extending with Custom SDL & Resolvers](./docs/extending_schema.md) +- [File Uploads](./docs/file_uploads.md) +- [Custom Middleware](./docs/middleware.md) +- [GraphQL Subscriptions](./docs/subscriptions.md) +- [Restrict Query/Mutation Depth](./docs/restrict_depth.md) +- Role Permissions are verified for `read` access +- Introspection Queries are disabled in Production. You can enable it using `site_config.enable_introspection_in_production` +- Standard Mutations + - [SET_VALUE Mutation](./docs/SET_VALUE.md) + - [SAVE_DOC Mutation](./docs/SAVE_DOC.md) + - [DELETE_DOC Mutation](./docs/DELETE_DOC.md) +- Helper Wrappers + - [Resolver Exception Handlers](./docs/resolvers_and_exceptions.md) + - [Role Permissions for Resolvers](./docs/resolver_role_permissions.md) ## Examples -### Adding a newly created DocType -- Generate the SDLs in your app directory -```bash -# Generate sdls for all doctypes in -$ bench --site test_site graphql generate_sdl --output-dir --app - -# Generate sdls for all doctype in module -$ bench --site test_site graphql generate_sdl --output-dir --module -m -m - -# Generate sdls for doctype <2> -$ bench --site test_site graphql generate_sdl --output-dir --doctype -dt -dt - -# Generate sdls for all doctypes in without Enums for Select Fields -$ bench --site test_site graphql generate_sdl --output-dir --app --disable-enum-select-fields -``` -- Specify this directory in `graphql_sdl_dir` hook and you are done. -### Introducing a Custom Field to GraphQL -- Add the `Custom Field` in frappe -- Add the following to a `.graphql` file in your app and specify its directory via `graphql_sdl_dir` -```graphql -extend type User { - is_super: Int! -} -``` -### Adding a Hello World Query -- Add a cmd in `graphql_schema_processors` hook -- Use the following function definition for the cmd specified: -```py -def hello_world(schema: GraphQLSchema): +- [Add new DocType](./docs/examples/add_new_doctype.md) +- [Introduce new Custom Field](./docs/examples/add_new_custom_field.md) +- [Add Hello World Query](./docs/examples/add_hello_world_query.md) +- [Add a New Mutation](./docs/examples/add_new_mutation.md) +- [Add Document Count per DocType Query](./docs/examples/add_document_count_query.md) - def hello_resolver(obj, info: GraphQLResolveInfo, **kwargs): - return f"Hello {kwargs.get('name')}!" +## License - schema.query_type.fields["hello"] = GraphQLField( - GraphQLString, - resolve=hello_resolver, - args={ - "name": GraphQLArgument( - type_=GraphQLString, - default_value="World" - ) - } - ) -``` -- Now, you can query like query like this: -```py -# Request -query Hello($var_name: String) { - hello(name: $var_name) -} - -# Variables -{ - "var_name": "Mars" -} - -# Response -{ - "data": { - "hello": "Hello Mars!" - } -} -``` -### Adding a DocMeta Query - -```py -# Add the cmd to the following function in `graphql_schema_processors` -def docmeta_query(schema): - from graphql import GraphQLField, GraphQLObjectType, GraphQLString, GraphQLInt - schema.query_type.fields["docmeta"] = GraphQLField( - GraphQLList(GraphQLObjectType( - name="DocTypeMeta", - fields={ - "name": GraphQLField( - GraphQLString, - resolve=lambda obj, info: obj - ), - "number_of_docs": GraphQLField( - GraphQLInt, - resolve=lambda obj, info: frappe.db.count(obj) - ), - } - )), - resolve=lambda obj, info: [x.name for x in frappe.get_all("DocType")] - ) -``` -Please refer `graphql-core` for more examples - -### Adding a new Mutation -There are two ways: -1. Write SDL and Attach Resolver to the Schema - ```graphql - # SDL for Mutation - type MY_MUTATION_OUTPUT_TYPE { - success: Boolean - } - - extend type Mutation { - myNewMutation(args): MY_MUTATION_OUTPUT_TYPE - } - ``` - - ```py - # Attach Resolver (Specify the cmd to this function in `graphql_schema_processors` hook) - def myMutationResolver(schema: GraphQLSchema): - def _myMutationResolver(obj: Any, info: GraphQLResolveInfo): - # frappe.set_value(..) - return { - "success": True - } - - mutation_type = schema.mutation_type - mutation_type.fields["myNewMutation"].resolve = _myMutationResolver - ``` - -2. Make use of `graphql-core` apis - ```py - # Specify the cmd to this function in `graphql_schema_processors` hook - def bindMyNewMutation(schema): - - def _myMutationResolver(obj: Any, info: GraphQLResolveInfo): - # frappe.set_value(..) - return { - "success": True - } - - mutation_type = schema.mutation_type - mutation_type.fields["myNewMutation"] = GraphQLField( - GraphQLObjectType( - name="MY_MUTATION_OUTPUT_TYPE", - fields={ - "success": GraphQLField( - GraphQLBoolean, - resolve=lambda obj, info: obj["success"] - ) - } - ), - resolve=_myMutationResolver - ) - ``` - -### Modify the Schema randomly -```py -def schema_processor(schema: GraphQLSchema): - schema.query_type.fields["hello"] = GraphQLField( - GraphQLString, resolve=lambda obj, info: "World!") -``` +MIT diff --git a/docs/default_get_doc_get_list.md b/docs/default_get_doc_get_list.md new file mode 100644 index 0000000..56e41fc --- /dev/null +++ b/docs/default_get_doc_get_list.md @@ -0,0 +1,39 @@ +## Getting single Document and getting a filtered doctype list + +You can get a single document by its name using `` query. +For sort by fields, only those fields that are search_indexed / unique can be used. NAME, CREATION & MODIFIED can also be used + +### Query + +``` +{ + User(name: "Administrator") { + name, + email + } +} +``` + +You can get a list of documents by querying ``. You can also pass in filters and sorting details as arguments: + +```graphql +{ + Users(filter: [["name", "like", "%a%"]], sortBy: { field: NAME, direction: ASC }) { + totalCount, + pageInfo { + hasNextPage, + hasPreviousPage, + startCursor, + endCursor + }, + edges { + cursor, + node { + name, + first_name + } + } + } + } +} +``` diff --git a/docs/examples/add_document_count_query.md b/docs/examples/add_document_count_query.md new file mode 100644 index 0000000..615826c --- /dev/null +++ b/docs/examples/add_document_count_query.md @@ -0,0 +1,25 @@ +# Adding a DocMeta Query + +```py +# Add the cmd to the following function in `graphql_schema_processors` +def docmeta_query(schema): + from graphql import GraphQLField, GraphQLObjectType, GraphQLString, GraphQLInt + schema.query_type.fields["docmeta"] = GraphQLField( + GraphQLList(GraphQLObjectType( + name="DocTypeMeta", + fields={ + "name": GraphQLField( + GraphQLString, + resolve=lambda obj, info: obj + ), + "number_of_docs": GraphQLField( + GraphQLInt, + resolve=lambda obj, info: frappe.db.count(obj) + ), + } + )), + resolve=lambda obj, info: [x.name for x in frappe.get_all("DocType")] + ) +``` + +Please refer `graphql-core` for more examples diff --git a/docs/examples/add_hello_world_query.md b/docs/examples/add_hello_world_query.md new file mode 100644 index 0000000..e14b6d4 --- /dev/null +++ b/docs/examples/add_hello_world_query.md @@ -0,0 +1,43 @@ +# Adding a Hello World Query + +- Add a cmd in `graphql_schema_processors` hook +- Use the following function definition for the cmd specified: + +```py +def hello_world(schema: GraphQLSchema): + + def hello_resolver(obj, info: GraphQLResolveInfo, **kwargs): + return f"Hello {kwargs.get('name')}!" + + schema.query_type.fields["hello"] = GraphQLField( + GraphQLString, + resolve=hello_resolver, + args={ + "name": GraphQLArgument( + type_=GraphQLString, + default_value="World" + ) + } + ) +``` + +- Now, you can query like query like this: + +```py +# Request +query Hello($var_name: String) { + hello(name: $var_name) +} + +# Variables +{ + "var_name": "Mars" +} + +# Response +{ + "data": { + "hello": "Hello Mars!" + } +} +``` diff --git a/docs/examples/add_new_custom_field.md b/docs/examples/add_new_custom_field.md new file mode 100644 index 0000000..3053b77 --- /dev/null +++ b/docs/examples/add_new_custom_field.md @@ -0,0 +1,10 @@ +## Introducing a Custom Field to GraphQL + +- Add the `Custom Field` in frappe +- Add the following to a `.graphql` file in your app and specify its directory via `graphql_sdl_dir` + +```graphql +extend type User { + is_super: Int! +} +``` diff --git a/docs/examples/add_new_doctype.md b/docs/examples/add_new_doctype.md new file mode 100644 index 0000000..0568071 --- /dev/null +++ b/docs/examples/add_new_doctype.md @@ -0,0 +1,19 @@ +## Adding a newly created DocType + +- Generate the SDLs in your app directory + + ```bash + # Generate SDLs for all doctypes in + $ bench --site test_site graphql generate_sdl --output-dir --app + + # Generate SDLs for all doctype in module + $ bench --site test_site graphql generate_sdl --output-dir --module -m -m + + # Generate SDLs for doctype <2> + $ bench --site test_site graphql generate_sdl --output-dir --doctype -dt -dt + + # Generate SDLs for all doctypes in without Enums for Select Fields + $ bench --site test_site graphql generate_sdl --output-dir --app --disable-enum-select-fields + ``` + +- Specify this directory in `graphql_sdl_dir` hook and you are done. diff --git a/docs/examples/add_new_mutation.md b/docs/examples/add_new_mutation.md new file mode 100644 index 0000000..81ed0d7 --- /dev/null +++ b/docs/examples/add_new_mutation.md @@ -0,0 +1,56 @@ +# Adding a new Mutation + +There are two ways: + +1. Write SDL and Attach Resolver to the Schema + + ```graphql + # SDL for Mutation + type MY_MUTATION_OUTPUT_TYPE { + success: Boolean + } + + extend type Mutation { + myNewMutation(args): MY_MUTATION_OUTPUT_TYPE + } + ``` + + ```py + # Attach Resolver (Specify the cmd to this function in `graphql_schema_processors` hook) + def myMutationResolver(schema: GraphQLSchema): + def _myMutationResolver(obj: Any, info: GraphQLResolveInfo): + # frappe.set_value(..) + return { + "success": True + } + + mutation_type = schema.mutation_type + mutation_type.fields["myNewMutation"].resolve = _myMutationResolver + ``` + +2. Make use of `graphql-core` apis + + ```py + # Specify the cmd to this function in `graphql_schema_processors` hook + def bindMyNewMutation(schema): + + def _myMutationResolver(obj: Any, info: GraphQLResolveInfo): + # frappe.set_value(..) + return { + "success": True + } + + mutation_type = schema.mutation_type + mutation_type.fields["myNewMutation"] = GraphQLField( + GraphQLObjectType( + name="MY_MUTATION_OUTPUT_TYPE", + fields={ + "success": GraphQLField( + GraphQLBoolean, + resolve=lambda obj, info: obj["success"] + ) + } + ), + resolve=_myMutationResolver + ) + ``` diff --git a/docs/extending_schema.md b/docs/extending_schema.md new file mode 100644 index 0000000..fb4c9cd --- /dev/null +++ b/docs/extending_schema.md @@ -0,0 +1,21 @@ +## Support Extensions via Hooks + +You can extend the SDLs with additional query / mutations / subscriptions. Examples are provided for a specific set of Scenarios. Please read [GraphQL Spec](http://spec.graphql.org/June2018/#sec-Object-Extensions) regarding Extending types. There are mainly two hooks introduced: + +- `graphql_sdl_dir` + Specify a list of directories containing `.graphql` files relative to the app's root directory. + eg: + +```py +# hooks.py +graphql_sdl_dir = [ + "./your-app/your-app/generated/sdl/dir1", + "./your-app/your-app/generated/sdl/dir2", +] +``` + +The above will look for graphql files in `your-bench/apps/your-app/your-app/generated/sdl/dir1` & `./dir2` folders. + +- `graphql_schema_processors` +You can pass in a list of cmd that gets executed on schema creation. You are given `GraphQLSchema` object (please refer [graphql-core](https://github.com/graphql-python/graphql-core)) as the only parameter. You can modify it or extend it as per your requirements. +This is a good place to attach the resolvers for the custom SDLs defined via `graphql_sdl_dir` \ No newline at end of file diff --git a/docs/file_uploads.md b/docs/file_uploads.md new file mode 100644 index 0000000..7d47856 --- /dev/null +++ b/docs/file_uploads.md @@ -0,0 +1,47 @@ +## File Uploads + +File uploads can be done following the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). `uploadFile` mutation is included implementing the same + +### Query + +```http +POST /api/method/graphql HTTP/1.1 +Host: test_site:8000 +Accept: application/json +Cookie: full_name=Administrator; sid=; system_user=yes; user_id=Administrator; user_image= +Content-Length: 553 +Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW + +----WebKitFormBoundary7MA4YWxkTrZu0gW +Content-Disposition: form-data; name="operations" + +{ + "query": "mutation uploadFile($file: Upload!) { uploadFile(file: $file) { name, file_url } }", + "variables": { + "file": null + } +} +----WebKitFormBoundary7MA4YWxkTrZu0gW +Content-Disposition: form-data; name="map" + +{ "0": ["variables.file"] } +----WebKitFormBoundary7MA4YWxkTrZu0gW +Content-Disposition: form-data; name="0"; filename="/D:/faztp12/Pictures/BingImageOfTheDay_20190715.jpg" +Content-Type: image/jpeg + +(data) +----WebKitFormBoundary7MA4YWxkTrZu0gW +``` + +Response + +```json +{ + "data": { + "uploadFile": { + "name": "ce36b2e222", + "file_url": "/files/BingImageOfTheDay_20190715.jpg" + } + } +} +``` diff --git a/docs/link_fields_nested.md b/docs/link_fields_nested.md new file mode 100644 index 0000000..2533818 --- /dev/null +++ b/docs/link_fields_nested.md @@ -0,0 +1,56 @@ +## Access Field Linked Documents in nested queries + +All Link fields return respective doc. Add `__name` suffix to the link field name to get the link name. + +### Query + +```gql +{ + ToDo(limit_page_length: 1) { + name + priority + description + assigned_by__name + assigned_by { + full_name + roles { + role__name + role { + name + creation + } + } + } + } +} +``` + +### Result + +```json +{ + "data": { + "ToDo": [ + { + "name": "ae6f39845b", + "priority": "Low", + "description": "

Do this

", + "assigned_by__name": "Administrator", + "assigned_by": { + "full_name": "Administrator", + "roles": [ + { + "role__name": "System Manager", + "role": { + "name": "System Manager", + "creation": "2021-02-02 08:34:42.170306", + } + } + ] + } + } + ... + ] + } +} +``` diff --git a/docs/middleware.md b/docs/middleware.md new file mode 100644 index 0000000..65b6e80 --- /dev/null +++ b/docs/middleware.md @@ -0,0 +1,23 @@ +## Support Extension of Middlewares via hooks + +We can add graphql middlewares by adding the path through hooks. +Please note the return type and arguments being passed to your custom middleware. + +```py +# hooks.py +graphql_middlewares = ["frappe_graphql.utils.middlewares.disable_introspection_queries.disable_introspection_queries"] +``` + +```py +def disable_introspection_queries(next_resolver, obj, info: GraphQLResolveInfo, **kwargs): + # https://github.com/jstacoder/graphene-disable-introspection-middleware + if is_introspection_disabled() and info.field_name.lower() in ['__schema', '__introspection']: + raise IntrospectionDisabled(frappe._("Introspection is disabled")) + + return next_resolver(obj, info, **kwargs) + + +def is_introspection_disabled(): + return not cint(frappe.local.conf.get("developer_mode")) and \ + not cint(frappe.local.conf.get("enable_introspection_in_production")) +``` diff --git a/docs/restrict_depth.md b/docs/restrict_depth.md new file mode 100644 index 0000000..9cf0f28 --- /dev/null +++ b/docs/restrict_depth.md @@ -0,0 +1,5 @@ +## Restrict Query/Mutation depth + +Query/Mutation is restricted by default to 10. + +You can change the depth limit by setting the site config `frappe_graphql_depth_limit: 15`.