Repository: igorkamyshev/farfetched
Pull request #269 (2023-02-15 12:06:01)

OpenAPI codegen
- [x] integration for `typed-contracts` 🤗

cloudflare-pages[bot]: ## Deploying with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>9ff33d4</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://4e55f7bc.farfetched.pages.dev'>https://4e55f7bc.farfetched.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://open-api-recipe.farfetched.pages.dev'>https://open-api-recipe.farfetched.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/?to=/:account/pages/view/farfetched/4e55f7bc-2f74-4ec4-a62e-f348c7f9cfbc)


Patch:
diff --git a/.changeset/angry-islands-compare.md b/.changeset/angry-islands-compare.md
new file mode 100644
index 0000000..42a2c59
--- /dev/null
+++ b/.changeset/angry-islands-compare.md
@@ -0,0 +1,5 @@
+---
+'@farfetched/core': minor
+---
+
+Allow to pass any string as `query` in `createJson*`
diff --git a/.changeset/eleven-deers-know.md b/.changeset/eleven-deers-know.md
new file mode 100644
index 0000000..41e9e9b
--- /dev/null
+++ b/.changeset/eleven-deers-know.md
@@ -0,0 +1,5 @@
+---
+'@farfetched/core': patch
+---
+
+Fix `.$stale` in initial state, now it is `true`
diff --git a/.changeset/loud-lobsters-turn.md b/.changeset/loud-lobsters-turn.md
new file mode 100644
index 0000000..8c8e168
--- /dev/null
+++ b/.changeset/loud-lobsters-turn.md
@@ -0,0 +1,5 @@
+---
+'@farfetched/typed-contracts': minor
+---
+
+Add integration for `typed-contracts`
diff --git a/apps/website/docs/.vitepress/config.js b/apps/website/docs/.vitepress/config.js
index fa488ed..fe6759b 100644
--- a/apps/website/docs/.vitepress/config.js
+++ b/apps/website/docs/.vitepress/config.js
@@ -241,6 +241,10 @@ export default withMermaid(
                     text: 'superstruct',
                     link: '/api/contracts/superstruct',
                   },
+                  {
+                    text: 'typed-contracts',
+                    link: '/api/contracts/typed-contracts',
+                  },
                 ],
               },
             ],
@@ -277,6 +281,10 @@ export default withMermaid(
                 text: 'Vite',
                 link: '/recipes/vite',
               },
+              {
+                text: 'Code generation with OpenAPI',
+                link: '/recipes/open_api',
+              },
             ],
           },
           {
diff --git a/apps/website/docs/api/contracts/typed-contracts.md b/apps/website/docs/api/contracts/typed-contracts.md
new file mode 100644
index 0000000..d02c8a6
--- /dev/null
+++ b/apps/website/docs/api/contracts/typed-contracts.md
@@ -0,0 +1,41 @@
+# Farfetched and typed-contracts
+
+Integration is distributed as a separate package, you have to install it and its peer dependencies before usage
+
+::: code-group
+
+```sh [pnpm]
+pnpm install typed-contracts @farfetched/typed-contracts
+```
+
+```sh [yarn]
+yarn add typed-contracts @farfetched/typed-contracts
+```
+
+```sh [npm]
+npm install typed-contracts @farfetched/typed-contracts
+```
+
+:::
+
+## `typedContract`
+
+Creates a [_Contract_](/api/primitives/contract) based on given `Contract` from `typed-contracts`.
+
+```ts
+import { literal, object, number } from 'typed-contracts';
+import { typedContract } from '@farfetched/typed-contracts';
+
+const Asteroid = object({
+  type: literal('asteroid'),
+  mass: number,
+});
+
+const asteroidContract = typedContract(Asteroid);
+
+/* typeof asteroidContract === Contract<
+ *   unknown, 👈 it accepts something unknown
+ *   { type: 'asteriod', mass: number }, 👈 and validates if it is an asteroid
+ * >
+ */
+```
diff --git a/apps/website/docs/api/factories/create_json_mutation.md b/apps/website/docs/api/factories/create_json_mutation.md
index 63baa10..a230584 100644
--- a/apps/website/docs/api/factories/create_json_mutation.md
+++ b/apps/website/docs/api/factories/create_json_mutation.md
@@ -16,7 +16,7 @@ Config fields:
   - `method`: _String_
   - `url`: _[Sourced](/api/primitives/sourced) string_
   - `body`: _[Sourced](/api/primitives/sourced) Json_, any value which can be serialized to JSON and parsed back without loses by JavaScript native module JSON. For example, `{ a: 1, b: 2 }`. Note that body cannot be used in `GET` and `HEAD` requests.
-  - `query?`: _[Sourced](/api/primitives/sourced) object_, keys of the object must be `String` and values must be `String` or `Array<String>`
+  - `query?`: _[Sourced](/api/primitives/sourced) object_, keys of the object must be `String` and values must be `String` or `Array<String>` or (since v0.8.0) _[Sourced](/api/primitives/sourced) String_ containing ready-to-use query string
   - `headers?`: _[Sourced](/api/primitives/sourced) object_, keys of the object must be `String` and values must be `String` or `Array<String>`
 
 - `response`: declarative rules to handle response from the API.
diff --git a/apps/website/docs/api/factories/create_json_query.md b/apps/website/docs/api/factories/create_json_query.md
index 482bec3..6fa05a9 100644
--- a/apps/website/docs/api/factories/create_json_query.md
+++ b/apps/website/docs/api/factories/create_json_query.md
@@ -18,7 +18,7 @@ Config fields:
   - `method`: _String_
   - `url`: _[Sourced](/api/primitives/sourced) string_
   - `body`: _[Sourced](/api/primitives/sourced) Json_, any value which can be serialized to JSON and parsed back without loses by JavaScript native module JSON. For example, `{ a: 1, b: 2 }`. Note that body cannot be used in `GET` and `HEAD` requests.
-  - `query?`: _[Sourced](/api/primitives/sourced) object_, keys of the object must be `String` and values must be `String` or `Array<String>`
+  - `query?`: _[Sourced](/api/primitives/sourced) object_, keys of the object must be `String` and values must be `String` or `Array<String>` or (since v0.8.0) _[Sourced](/api/primitives/sourced) String_ containing ready-to-use query string
   - `headers?`: _[Sourced](/api/primitives/sourced) object_, keys of the object must be `String` and values must be `String` or `Array<String>`
 
 - `response`: declarative rules to handle response from the API.
diff --git a/apps/website/docs/recipes/open_api.md b/apps/website/docs/recipes/open_api.md
new file mode 100644
index 0000000..47c34ed
--- /dev/null
+++ b/apps/website/docs/recipes/open_api.md
@@ -0,0 +1,131 @@
+# Code generation with OpenAPI
+
+Farfetched itself does not have any code generation capabilities. However, it is possible to use the OpenAPI specification to generate code for the client with external open-source tools.
+
+In this recipe, we will use the [OpenAPI Generator](https://github.com/openapi/openapi) with a special [preset for Effector](https://github.com/openapi/preset-effector).
+
+## Preparations
+
+1. Install the OpenAPI Generator and the Effector preset:
+
+::: code-group
+
+```sh [pnpm]
+pnpm install --dev openapi@^1.0.0 openapi-preset-effector typed-contracts
+```
+
+```sh [yarn]
+yarn add --dev openapi@^1.0.0 openapi-preset-effector typed-contracts
+```
+
+```sh [npm]
+npm install --dev openapi@^1.0.0 openapi-preset-effector typed-contracts
+```
+
+:::
+
+:::warning
+`openapi-preset-effector` supports only [`typed-contracts`](https://github.com/bigslycat/typed-contracts) for now, but integrations for [`runtypes`](https://github.com/openapi/preset-effector/issues/12), [`io-ts`](https://github.com/openapi/preset-effector/issues/13) and [`superstruct`](https://github.com/openapi/preset-effector/issues/19) on their ways.
+
+To prevent using many contract systems in your project, we recommend using `typed-contracts` across the whole project. Farfetched supports it out of the box, read more the API reference for [`@farfetched/typed-contracts`](/api/contracts/typed-contracts).
+:::
+
+2. Create base [_Effect_](https://effector.dev/docs/api/effector/effect) that will be used in the generated code, put it in the `./src/api/shared/request.ts` file. In general, it has to be any [_Effect_](https://effector.dev/docs/api/effector/effect) with the following signature:
+
+```ts
+export interface Request {
+  path: string;
+  method: string;
+  body?: object | null | void;
+  query?: Record<string, string>;
+  headers?: Record<string, string>;
+  cookies?: string;
+}
+
+export interface Answer {
+  ok: boolean;
+  body: unkown;
+  status: number;
+  headers: Record<string, string>;
+}
+
+export const requestFx = createEffect<Request, Answer, Answer>({
+  handler: async (request) => {
+    // ...
+  },
+});
+```
+
+:::tip
+The full code of the file is out of scope of this recipe, but you can find it in the [example repository](https://github.com/accesso-app/frontend/blob/master/src/shared/api/request/client.ts).
+:::
+
+3. Create a configuration file `openapi.config.js` in the root of your project with the following contents:
+
+```js
+module.exports = {
+  file: 'https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v2.0/json/petstore-minimal.json',
+  outputDir: './src/api/shared',
+  presets: [
+    [
+      'openapi-preset-effector',
+      {
+        effectorImport: 'effector',
+        requestName: 'fetchFx',
+        requestPath: './request',
+      },
+    ],
+  ],
+};
+```
+
+:::tip
+This recipe is based on the official example from the OpenAPI specification repository — [perstore-minimal.json](https://github.com/OAI/OpenAPI-Specification/blob/main/examples/v2.0/json/petstore-minimal.json).
+:::
+
+## Generation
+
+You are ready to generate the code. Run the following command:
+
+::: code-group
+
+```sh [pnpm]
+pnpm openapi
+```
+
+```sh [yarn]
+yarn openapi
+```
+
+```sh [npm]
+npm openapi
+```
+
+:::
+
+The generated code will be placed in the `./src/api/shared` directory. You must not touch it, because it will be overwritten on the next generation.
+
+## Make it farfetched
+
+Generated code contains [_Effects_](https://effector.dev/docs/api/effector/effect), but Farfetched's APIs work only with [_Query_](/api/primitives/query) or [_Mutation_](/api/primitives/mutation). So, we need to wrap the generated code with Farfetched's APIs. Let's create a file `./src/api/shared/index.ts` with the following contents:
+
+```ts
+import { createQuery } from '@farfetched/core';
+
+import { petsGet } from './swagger-petstore';
+
+export const petsQuery = createQuery({ effect: petsGet });
+```
+
+Now, you can use `petsQuery` in your code as simple as any other [_Query_](/api/primitives/query), for example:
+
+```ts
+import { retry } from '@farfetched/core';
+
+import { petsQuery } from './api/shared';
+
+retry(petsQuery, {
+  times: 3,
+  delay: 1000,
+});
+```
diff --git a/apps/website/docs/roadmap.md b/apps/website/docs/roadmap.md
index d776c1e..7379a8b 100644
--- a/apps/website/docs/roadmap.md
+++ b/apps/website/docs/roadmap.md
@@ -6,16 +6,6 @@ Farfetched is under development now, before the first stable release we are goin
 
 ### v.0.8
 
-- Code generation for Open API
-
-## Future releases
-
-### v.0.9
-
-- DevTools interface
-
-### v.0.10
-
 - Triggers API to mark data as stale and re-fetch on some declarative triggers.
 - Add-on `@farfetched/web-api` with common triggers
   - internet connectivity after lost
@@ -25,11 +15,17 @@ Farfetched is under development now, before the first stable release we are goin
 - migrate Mutation API to Triggers API
 - migrate `connectQuery` to Triggers API
 
-### v.0.11
+## Future releases
+
+### v.0.9
+
+- DevTools interface
+
+### v.0.10
 
 - Pagination and infinite scroll
 
-### v.0.12
+### v.0.11
 
 - GraphQL add-on — `@farfetched/graphql`
   - `createGraphQLQuery`
@@ -38,13 +34,13 @@ Farfetched is under development now, before the first stable release we are goin
   - import, parsing, and usage of schema
   - automatic optimistic update for Query based on Mutation and schema
 
-### v.0.13
+### v.0.12
 
 - WebSocket
 - Server Sent Events
 - HTTP Polling
 
-### v.0.14
+### v.0.13
 
 - Add-on `@farfetched/rest`
   - `createREST` — return set of Queries and Mutations for typical REST API
diff --git a/apps/website/docs/tutorial/contracts.md b/apps/website/docs/tutorial/contracts.md
index 9881463..85c5f03 100644
--- a/apps/website/docs/tutorial/contracts.md
+++ b/apps/website/docs/tutorial/contracts.md
@@ -97,5 +97,6 @@ Farfetched provides integrations for the following third-party solutions:
 - [Zod](/api/contracts/zod)
 - [io-ts](/api/contracts/io-ts)
 - [Superstruct](/api/contracts/superstruct)
+- [typed-contracts](/api/contracts/typed-contracts)
 
 If you are using any other solution for contracts, you can easily create a wrapper for it to case your internal contracts to Farfetched [_Contract_](/api/primitives/contract). Check source code of [Runtypes integration](https://github.com/igorkamyshev/farfetched/blob/master/packages/runtypes/src/runtype_contract.ts) for inspiration.
diff --git a/package.json b/package.json
index e85427e..257552f 100644
--- a/package.json
+++ b/package.json
@@ -79,6 +79,7 @@
     "react-dom": "18.2.0",
     "react-router-dom": "^6.4.2",
     "runtypes": "^6.5.1",
-    "solid-js": "1.4.8"
+    "solid-js": "1.4.8",
+    "typed-contracts": "^3.0.0"
   }
 }
diff --git a/packages/core/project.json b/packages/core/project.json
index e4b2e08..ade8ebd 100644
--- a/packages/core/project.json
+++ b/packages/core/project.json
@@ -71,7 +71,7 @@
     "size": {
       "executor": "./tools/executors/size-limit:size-limit",
       "options": {
-        "limit": "16.6 kB",
+        "limit": "16.7 kB",
         "outputPath": "dist/packages/core"
       },
       "dependsOn": [
diff --git a/packages/core/src/fetch/api.ts b/packages/core/src/fetch/api.ts
index 63c4fc8..ca81f4a 100644
--- a/packages/core/src/fetch/api.ts
+++ b/packages/core/src/fetch/api.ts
@@ -35,6 +35,7 @@ import {
   mergeRecords,
   formatHeaders,
   type FetchApiRecord,
+  mergeQueryStrings,
 } from './lib';
 import { requestFx } from './request';
 
@@ -68,7 +69,7 @@ export interface ExclusiveRequestConfig<B>
 
 // These settings can be defined twice — both statically and dynamically, they will be merged
 export interface InclusiveRequestConfig {
-  query?: FetchApiRecord;
+  query?: FetchApiRecord | string;
   headers?: FetchApiRecord;
 }
 
@@ -282,7 +283,7 @@ export function createApiRequest<
 
       // Inclusive settings
 
-      const query = mergeRecords(staticConfig.query, dynamicConfig.query);
+      const query = mergeQueryStrings(staticConfig.query, dynamicConfig.query);
       const headers = mergeRecords(staticConfig.headers, dynamicConfig.headers);
 
       // Other settings
diff --git a/packages/core/src/fetch/lib.ts b/packages/core/src/fetch/lib.ts
index 70c0bf9..3e0cac0 100644
--- a/packages/core/src/fetch/lib.ts
+++ b/packages/core/src/fetch/lib.ts
@@ -21,6 +21,28 @@ export function mergeRecords(
   return final;
 }
 
+export function mergeQueryStrings(
+  ...queryStrings: (FetchApiRecord | string | undefined | null)[]
+): string {
+  const final: string[] = [];
+
+  for (const item of queryStrings) {
+    if (!item) {
+      continue;
+    }
+
+    let curr: string;
+    if (typeof item !== 'string') {
+      curr = recordToUrlSearchParams(item).toString();
+    } else {
+      curr = item;
+    }
+    final.push(curr);
+  }
+
+  return final.join('&');
+}
+
 export function formatHeaders(headersRecord: FetchApiRecord): Headers {
   const headers = new Headers();
   for (const [key, value] of Object.entries(headersRecord)) {
@@ -38,25 +60,40 @@ export function formatHeaders(headersRecord: FetchApiRecord): Headers {
   return headers;
 }
 
-export function formatUrl(url: string, queryRecord: FetchApiRecord): string {
-  const query = new URLSearchParams();
-  for (const [key, value] of Object.entries(queryRecord)) {
+export function formatUrl(
+  url: string,
+  queryRecord: FetchApiRecord | string
+): string {
+  let queryString: string;
+
+  if (typeof queryRecord === 'string') {
+    queryString = queryRecord;
+  } else {
+    queryString = recordToUrlSearchParams(queryRecord).toString();
+  }
+
+  if (!queryString) {
+    return url;
+  }
+
+  return `${url}?${queryString}`;
+}
+
+function recordToUrlSearchParams(record: FetchApiRecord): URLSearchParams {
+  const params = new URLSearchParams();
+
+  for (const [key, value] of Object.entries(record)) {
     const cleanValue = clearValue(value);
     if (Array.isArray(cleanValue)) {
       for (const v of cleanValue) {
-        query.append(key, v);
+        params.append(key, v);
       }
     } else {
-      query.append(key, cleanValue);
+      params.append(key, cleanValue);
     }
   }
-  const queryString = query.toString();
-
-  if (!queryString) {
-    return url;
-  }
 
-  return `${url}?${queryString}`;
+  return params;
 }
 
 function clearValue(value: string | string[] | number): string | string[] {
diff --git a/packages/core/src/mutation/create_json_mutation.ts b/packages/core/src/mutation/create_json_mutation.ts
index 7c3f4e2..f24c6fd 100644
--- a/packages/core/src/mutation/create_json_mutation.ts
+++ b/packages/core/src/mutation/create_json_mutation.ts
@@ -29,7 +29,9 @@ type RequestConfig<Params, BodySource, QuerySource, HeadersSource, UrlSource> =
   {
     url: SourcedField<Params, string, UrlSource>;
     credentials?: RequestCredentials;
-    query?: SourcedField<Params, FetchApiRecord, QuerySource>;
+    query?:
+      | SourcedField<Params, FetchApiRecord, QuerySource>
+      | SourcedField<Params, string, QuerySource>;
     headers?: SourcedField<Params, FetchApiRecord, HeadersSource>;
   } & (
     | {
diff --git a/packages/core/src/query/create_headless_query.ts b/packages/core/src/query/create_headless_query.ts
index 0f88cc8..6e3779e 100644
--- a/packages/core/src/query/create_headless_query.ts
+++ b/packages/core/src/query/create_headless_query.ts
@@ -97,7 +97,7 @@ export function createHeadlessQuery<
     name: `${operation.__.meta.name}.$error`,
     serialize: serializationForSideStore(serialize),
   });
-  const $stale = createStore<boolean>(false, {
+  const $stale = createStore<boolean>(true, {
     sid: `ff.${operation.__.meta.name}.$stale`,
     name: `${operation.__.meta.name}.$stale`,
     serialize: serializationForSideStore(serialize),
@@ -121,15 +121,7 @@ export function createHeadlessQuery<
 
   sample({
     clock: operation.finished.finally,
-    filter: ({ meta }) => !meta.isFreshData,
-    fn: () => true,
-    target: $stale,
-  });
-
-  sample({
-    clock: operation.finished.finally,
-    filter: ({ meta }) => meta.isFreshData,
-    fn: () => false,
+    fn: ({ meta }) => !meta.isFreshData,
     target: $stale,
   });
 
diff --git a/packages/core/src/query/create_json_query.ts b/packages/core/src/query/create_json_query.ts
index b6f4420..68a07e0 100644
--- a/packages/core/src/query/create_json_query.ts
+++ b/packages/core/src/query/create_json_query.ts
@@ -2,7 +2,7 @@ import { attach, createEvent, Event, sample, type Json } from 'effector';
 
 import { Contract } from '../contract/type';
 import { createJsonApiRequest } from '../fetch/json';
-import { ApiRequestError, HttpMethod, JsonApiRequestError } from '../fetch/api';
+import { HttpMethod, JsonApiRequestError } from '../fetch/api';
 import {
   normalizeSourced,
   type SourcedField,
@@ -16,7 +16,6 @@ import {
   SharedQueryFactoryConfig,
 } from './create_headless_query';
 import { unknownContract } from '../contract/unknown_contract';
-import { InvalidDataError } from '../errors/type';
 import { Validator } from '../validation/type';
 
 // -- Shared
@@ -30,7 +29,9 @@ type RequestConfig<Params, BodySource, QuerySource, HeadersSource, UrlSource> =
   {
     url: SourcedField<Params, string, UrlSource>;
     credentials?: RequestCredentials;
-    query?: SourcedField<Params, FetchApiRecord, QuerySource>;
+    query?:
+      | SourcedField<Params, FetchApiRecord, QuerySource>
+      | SourcedField<Params, string, QuerySource>;
     headers?: SourcedField<Params, FetchApiRecord, HeadersSource>;
   } & (
     | {
diff --git a/packages/typed-contracts/.babelrc b/packages/typed-contracts/.babelrc
new file mode 100644
index 0000000..7658743
--- /dev/null
+++ b/packages/typed-contracts/.babelrc
@@ -0,0 +1,10 @@
+{
+  "presets": [
+    [
+      "@nrwl/js/babel",
+      {
+        "useBuiltIns": false
+      }
+    ]
+  ]
+}
diff --git a/packages/typed-contracts/.eslintrc.json b/packages/typed-contracts/.eslintrc.json
new file mode 100644
index 0000000..b5a1809
--- /dev/null
+++ b/packages/typed-contracts/.eslintrc.json
@@ -0,0 +1,18 @@
+{
+  "extends": ["../../.eslintrc.json"],
+  "ignorePatterns": ["!**/*", "node_modules"],
+  "overrides": [
+    {
+      "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+      "rules": {}
+    },
+    {
+      "files": ["*.ts", "*.tsx"],
+      "rules": {}
+    },
+    {
+      "files": ["*.js", "*.jsx"],
+      "rules": {}
+    }
+  ]
+}
diff --git a/packages/typed-contracts/CHANGELOG.md b/packages/typed-contracts/CHANGELOG.md
new file mode 100644
index 0000000..29d98a6
--- /dev/null
+++ b/packages/typed-contracts/CHANGELOG.md
@@ -0,0 +1,1 @@
+# @farfetched/typed-contracts
diff --git a/packages/typed-contracts/README.md b/packages/typed-contracts/README.md
new file mode 100644
index 0000000..bd00819
--- /dev/null
+++ b/packages/typed-contracts/README.md
@@ -0,0 +1,11 @@
+# typed-contracts
+
+This library was generated with [Nx](https://nx.dev).
+
+## Building
+
+Run `nx build typed-contracts` to build the library.
+
+## Running unit tests
+
+Run `nx test typed-contracts` to execute the unit tests via [Jest](https://jestjs.io).
diff --git a/packages/typed-contracts/index.ts b/packages/typed-contracts/index.ts
new file mode 100644
index 0000000..7f76a9b
--- /dev/null
+++ b/packages/typed-contracts/index.ts
@@ -0,0 +1,1 @@
+export { typedContract } from './src/contract';
diff --git a/packages/typed-contracts/package.json b/packages/typed-contracts/package.json
new file mode 100644
index 0000000..dbff830
--- /dev/null
+++ b/packages/typed-contracts/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "@farfetched/typed-contracts",
+  "version": "0.7.2",
+  "type": "commonjs",
+  "peerDependencies": {
+    "typed-contracts": "^3.0.0"
+  }
+}
diff --git a/packages/typed-contracts/project.json b/packages/typed-contracts/project.json
new file mode 100644
index 0000000..af0b556
--- /dev/null
+++ b/packages/typed-contracts/project.json
@@ -0,0 +1,80 @@
+{
+  "name": "typed-contracts",
+  "$schema": "../../node_modules/nx/schemas/project-schema.json",
+  "sourceRoot": "packages/typed-contracts/src",
+  "projectType": "library",
+  "targets": {
+    "pack": {
+      "executor": "nx:run-commands",
+      "options": {
+        "command": "node tools/scripts/typepack.mjs --package typed-contracts"
+      },
+      "dependsOn": [
+        {
+          "projects": "self",
+          "target": "build"
+        }
+      ]
+    },
+    "build": {
+      "executor": "@nrwl/rollup:rollup",
+      "outputs": ["{options.outputPath}"],
+      "options": {
+        "project": "packages/typed-contracts/package.json",
+        "outputPath": "dist/packages/typed-contracts",
+        "entryFile": "packages/typed-contracts/index.ts",
+        "tsConfig": "packages/typed-contracts/tsconfig.json",
+        "format": ["esm", "cjs"],
+        "generateExportsField": true,
+        "compiler": "babel"
+      }
+    },
+    "publish": {
+      "executor": "nx:run-commands",
+      "options": {
+        "command": "node tools/scripts/publish.mjs typed-contracts"
+      },
+      "dependsOn": [
+        {
+          "projects": "self",
+          "target": "pack"
+        }
+      ]
+    },
+    "lint": {
+      "executor": "@nrwl/linter:eslint",
+      "outputs": ["{options.outputFile}"],
+      "options": {
+        "lintFilePatterns": ["packages/typed-contracts/**/*.ts"]
+      }
+    },
+    "test": {
+      "executor": "@nrwl/vite:test",
+      "options": {
+        "config": "vite.config.ts"
+      }
+    },
+    "typetest": {
+      "executor": "@nrwl/vite:test",
+      "options": {
+        "config": "vite.config.ts",
+        "mode": "typecheck"
+      }
+    },
+    "size": {
+      "executor": "./tools/executors/size-limit:size-limit",
+      "options": {
+        "limit": "0.6 kB",
+        "outputPath": "dist/packages/typed-contracts"
+      },
+      "dependsOn": [
+        {
+          "projects": "self",
+          "target": "build"
+        }
+      ]
+    }
+  },
+  "implicitDependencies": ["!test-utils"],
+  "tags": []
+}
diff --git a/packages/typed-contracts/src/contract.ts b/packages/typed-contracts/src/contract.ts
new file mode 100644
index 0000000..29a6b0c
--- /dev/null
+++ b/packages/typed-contracts/src/contract.ts
@@ -0,0 +1,49 @@
+import {
+  type Contract as TypedContract,
+  ValidationError,
+} from 'typed-contracts';
+import { Contract } from '@farfetched/core';
+
+/**
+ * Transforms typed-contracts Contract for `data` to internal Contract.
+ * Any response which does not conform to `data` will be treated as error.
+ *
+ * @param data typed-contarcts Contract for valid data
+ */
+export function typedContract<D>(data: TypedContract<D>): Contract<unknown, D> {
+  const NAME = 'value';
+  return {
+    isData: (raw: unknown): raw is D =>
+      !(data(NAME, raw) instanceof ValidationError),
+    getErrorMessages(raw) {
+      const error = data(NAME, raw);
+      if (!(error instanceof ValidationError)) {
+        return [];
+      }
+
+      return traverseError(error);
+    },
+  };
+}
+
+function traverseError(
+  error: ValidationError,
+  prevMessages: string[] = []
+): string[] {
+  const messages: string[] = [...prevMessages];
+
+  if (error.nested.length > 0) {
+    const nestedMessages = error.nested.flatMap((e) =>
+      traverseError(e, messages)
+    );
+
+    return [...messages, ...nestedMessages];
+  }
+
+  return [
+    ...messages,
+    // typed-contracts does not provide a way to get error message in typings
+    // https://github.com/bigslycat/typed-contracts/issues/126
+    (error as any).message,
+  ];
+}
diff --git a/packages/typed-contracts/tsconfig.json b/packages/typed-contracts/tsconfig.json
new file mode 100644
index 0000000..5660c37
--- /dev/null
+++ b/packages/typed-contracts/tsconfig.json
@@ -0,0 +1,14 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "forceConsistentCasingInFileNames": true,
+    "strict": true,
+    "noImplicitOverride": true,
+    "noPropertyAccessFromIndexSignature": true,
+    "noImplicitReturns": true,
+    "noFallthroughCasesInSwitch": true,
+    "declaration": true
+  },
+  "files": [],
+  "include": ["**/*.ts"]
+}
diff --git a/packages/typed-contracts/vite.config.ts b/packages/typed-contracts/vite.config.ts
new file mode 100644
index 0000000..cf641af
--- /dev/null
+++ b/packages/typed-contracts/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+import tsconfigPaths from 'vite-tsconfig-paths';
+
+export default defineConfig({
+  test: { typecheck: { ignoreSourceErrors: true }, passWithNoTests: true },
+  plugins: [tsconfigPaths()],
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e8801ed..baeb70d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -56,6 +56,7 @@ importers:
       solid-js: 1.4.8
       solid-testing-library: ^0.3.0
       superstruct: ^1.0.3
+      typed-contracts: ^3.0.0
       typescript: 4.8.4
       vite: ^4.0.1
       vite-plugin-eslint: ^1.8.1
@@ -78,6 +79,7 @@ importers:
       react-router-dom: 6.4.2_biqbaboplfbrettd7655fr4n2y
       runtypes: 6.5.1
       solid-js: 1.4.8
+      typed-contracts: 3.0.0
     devDependencies:
       '@algolia/client-search': 4.14.2
       '@babel/core': 7.18.6
@@ -9614,6 +9616,10 @@ packages:
     engines: {node: '>=8'}
     dev: true
 
+  /typed-contracts/3.0.0:
+    resolution: {integrity: sha512-Crw4cKiPBulqy2YFfCMU3OaudtDPeEDwbkbYj6mI2XZA2wt3iGBvxvMobESW9t73+hlIpwe/4i63M+BzkprW+g==}
+    dev: false
+
   /typescript/4.8.4:
     resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==}
     engines: {node: '>=4.2.0'}
diff --git a/workspace.json b/workspace.json
index 250daed..e78525c 100644
--- a/workspace.json
+++ b/workspace.json
@@ -11,6 +11,7 @@
     "solid": "packages/solid",
     "superstruct": "packages/superstruct",
     "test-utils": "packages/test-utils",
+    "typed-contracts": "packages/typed-contracts",
     "website": "apps/website",
     "zod": "packages/zod"
   }


Test patch:
diff --git a/packages/core/src/cache/__test__/cache.test.ts b/packages/core/src/cache/__test__/cache.test.ts
index 32e5f01..2cc52a4 100644
--- a/packages/core/src/cache/__test__/cache.test.ts
+++ b/packages/core/src/cache/__test__/cache.test.ts
@@ -159,7 +159,7 @@ describe('cache', () => {
 
     // Value from cache is not a number, did not put it to cache
     expect(scope.getState(query.$data)).toEqual(null);
-    expect(scope.getState(query.$stale)).toBeFalsy();
+    expect(scope.getState(query.$stale)).toBeTruthy();
   });
 
   test('save value to cache before mapData call', async () => {
@@ -212,7 +212,7 @@ describe('cache', () => {
 
     // Value from cache not found
     expect(scope.getState(query.$data)).toEqual(null);
-    expect(scope.getState(query.$stale)).toBeFalsy();
+    expect(scope.getState(query.$stale)).toBeTruthy();
 
     await allSettled(scope);
 
@@ -255,7 +255,7 @@ describe('cache', () => {
 
     // Value from cache
     expect(scope.getState(query.$data)).toEqual(null);
-    expect(scope.getState(query.$stale)).toBeFalsy();
+    expect(scope.getState(query.$stale)).toBeTruthy();
 
     await allSettled(scope);
 
@@ -390,7 +390,7 @@ describe('cache', () => {
 
     // No value from cache
     expect(scope.getState(query.$data)).toBeNull();
-    expect(scope.getState(query.$stale)).toBeFalsy();
+    expect(scope.getState(query.$stale)).toBeTruthy();
 
     await allSettled(scope);
 
diff --git a/packages/core/src/fetch/__tests__/api.request.query.test.ts b/packages/core/src/fetch/__tests__/api.request.query.test.ts
index bded295..b1f8720 100644
--- a/packages/core/src/fetch/__tests__/api.request.query.test.ts
+++ b/packages/core/src/fetch/__tests__/api.request.query.test.ts
@@ -55,7 +55,7 @@ describe('fetch/api.request.query', () => {
   });
 
   test('pass reactive query on creation to request', async () => {
-    const $query = createStore<FetchApiRecord>({ test: 'value' });
+    const $query = createStore<FetchApiRecord | string>({ test: 'value' });
 
     const callApiFx = createApiRequest({
       request: { mapBody, method, url, credentials, query: $query },
@@ -103,7 +103,7 @@ describe('fetch/api.request.query', () => {
     });
 
     expect(fetchMock.mock.calls[0][0].url).toEqual(
-      `${url}/?static=one&shared=static&shared=dynamic&dynamic=two`
+      `${url}/?static=one&shared=static&dynamic=two&shared=dynamic`
     );
   });
 });
diff --git a/packages/core/src/fetch/__tests__/json.request.query.test.ts b/packages/core/src/fetch/__tests__/json.request.query.test.ts
new file mode 100644
index 0000000..a99b6a0
--- /dev/null
+++ b/packages/core/src/fetch/__tests__/json.request.query.test.ts
@@ -0,0 +1,197 @@
+import { allSettled, fork } from 'effector';
+import { describe, test, expect, vi } from 'vitest';
+
+import { fetchFx } from '../fetch';
+import { createJsonApiRequest } from '../json';
+
+describe('fetch/json.request.query', () => {
+  test('pass static string as is', async () => {
+    const callJsonApiFx = createJsonApiRequest({
+      request: {
+        method: 'GET' as const,
+        url: 'https://api.salo.com',
+        credentials: 'same-origin' as const,
+        query: 'foo[]=1&foo[]=2&foo[]=3',
+      },
+    });
+
+    const fetchMock = vi.fn().mockResolvedValue(new Response('Ok'));
+
+    const scope = fork({ handlers: [[fetchFx, fetchMock]] });
+
+    await allSettled(callJsonApiFx, {
+      scope,
+      params: {},
+    });
+
+    expect(fetchMock.mock.calls[0][0].url).toBe(
+      'https://api.salo.com/?foo[]=1&foo[]=2&foo[]=3'
+    );
+  });
+
+  test('merge static and dynamic string', async () => {
+    const callJsonApiFx = createJsonApiRequest({
+      request: {
+        method: 'GET' as const,
+        url: 'https://api.salo.com',
+        credentials: 'same-origin' as const,
+        query: 'foo[]=1&foo[]=2&foo[]=3',
+      },
+    });
+
+    const fetchMock = vi.fn().mockResolvedValue(new Response('Ok'));
+
+    const scope = fork({ handlers: [[fetchFx, fetchMock]] });
+
+    await allSettled(callJsonApiFx, {
+      scope,
+      params: { query: 'lol=kek' },
+    });
+
+    expect(fetchMock.mock.calls[0][0].url).toBe(
+      'https://api.salo.com/?foo[]=1&foo[]=2&foo[]=3&lol=kek'
+    );
+  });
+
+  test('pass dynamic string as is', async () => {
+    const callJsonApiFx = createJsonApiRequest({
+      request: {
+        method: 'GET' as const,
+        url: 'https://api.salo.com',
+        credentials: 'same-origin' as const,
+      },
+    });
+
+    const fetchMock = vi.fn().mockResolvedValue(new Response('Ok'));
+
+    const scope = fork({ handlers: [[fetchFx, fetchMock]] });
+
+    await allSettled(callJsonApiFx, {
+      scope,
+      params: { query: 'foo[]=1&foo[]=2&foo[]=3' },
+    });
+
+    expect(fetchMock.mock.calls[0][0].url).toBe(
+      'https://api.salo.com/?foo[]=1&foo[]=2&foo[]=3'
+    );
+  });
+
+  test('pass dynamic object as is', async () => {
+    const callJsonApiFx = createJsonApiRequest({
+      request: {
+        method: 'GET' as const,
+        url: 'https://api.salo.com',
+        credentials: 'same-origin' as const,
+      },
+    });
+
+    const fetchMock = vi.fn().mockResolvedValue(new Response('Ok'));
+
+    const scope = fork({ handlers: [[fetchFx, fetchMock]] });
+
+    await allSettled(callJsonApiFx, {
+      scope,
+      params: { query: { foo: 1, bar: 'val' } },
+    });
+
+    expect(fetchMock.mock.calls[0][0].url).toBe(
+      'https://api.salo.com/?foo=1&bar=val'
+    );
+  });
+
+  test('merge static object and dynamic object', async () => {
+    const callJsonApiFx = createJsonApiRequest({
+      request: {
+        method: 'GET' as const,
+        url: 'https://api.salo.com',
+        credentials: 'same-origin' as const,
+        query: { foo: 1 },
+      },
+    });
+
+    const fetchMock = vi.fn().mockResolvedValue(new Response('Ok'));
+
+    const scope = fork({ handlers: [[fetchFx, fetchMock]] });
+
+    await allSettled(callJsonApiFx, {
+      scope,
+      params: { query: { bar: 'val' } },
+    });
+
+    expect(fetchMock.mock.calls[0][0].url).toBe(
+      'https://api.salo.com/?foo=1&bar=val'
+    );
+  });
+
+  test('pass static object as is', async () => {
+    const callJsonApiFx = createJsonApiRequest({
+      request: {
+        method: 'GET' as const,
+        url: 'https://api.salo.com',
+        credentials: 'same-origin' as const,
+        query: { foo: 1, bar: 'val' },
+      },
+    });
+
+    const fetchMock = vi.fn().mockResolvedValue(new Response('Ok'));
+
+    const scope = fork({ handlers: [[fetchFx, fetchMock]] });
+
+    await allSettled(callJsonApiFx, {
+      scope,
+      params: {},
+    });
+
+    expect(fetchMock.mock.calls[0][0].url).toBe(
+      'https://api.salo.com/?foo=1&bar=val'
+    );
+  });
+
+  test('merge static string and dyamic object', async () => {
+    const callJsonApiFx = createJsonApiRequest({
+      request: {
+        method: 'GET' as const,
+        url: 'https://api.salo.com',
+        credentials: 'same-origin' as const,
+        query: 'foo[]=1&foo[]=2&foo[]=3',
+      },
+    });
+
+    const fetchMock = vi.fn().mockResolvedValue(new Response('Ok'));
+
+    const scope = fork({ handlers: [[fetchFx, fetchMock]] });
+
+    await allSettled(callJsonApiFx, {
+      scope,
+      params: { query: { bar: 'val' } },
+    });
+
+    expect(fetchMock.mock.calls[0][0].url).toBe(
+      'https://api.salo.com/?foo[]=1&foo[]=2&foo[]=3&bar=val'
+    );
+  });
+
+  test('merge static object and dyamic string', async () => {
+    const callJsonApiFx = createJsonApiRequest({
+      request: {
+        method: 'GET' as const,
+        url: 'https://api.salo.com',
+        credentials: 'same-origin' as const,
+        query: { bar: 'val' },
+      },
+    });
+
+    const fetchMock = vi.fn().mockResolvedValue(new Response('Ok'));
+
+    const scope = fork({ handlers: [[fetchFx, fetchMock]] });
+
+    await allSettled(callJsonApiFx, {
+      scope,
+      params: { query: 'foo[]=1&foo[]=2&foo[]=3' },
+    });
+
+    expect(fetchMock.mock.calls[0][0].url).toBe(
+      'https://api.salo.com/?bar=val&foo[]=1&foo[]=2&foo[]=3'
+    );
+  });
+});
diff --git a/packages/core/src/mutation/__tests__/create_json_mutation.query.test-d.ts b/packages/core/src/mutation/__tests__/create_json_mutation.query.test-d.ts
new file mode 100644
index 0000000..f434b58
--- /dev/null
+++ b/packages/core/src/mutation/__tests__/create_json_mutation.query.test-d.ts
@@ -0,0 +1,38 @@
+import { describe, test } from 'vitest';
+
+import { Contract } from '../../contract/type';
+import { createStore } from 'effector';
+import { createJsonMutation } from '../create_json_mutation';
+
+describe('createJsonQuery query', () => {
+  const response = {
+    contract: {} as Contract<unknown, unknown>,
+    mapData: <T>({ result }: { result: T }) => result,
+  };
+  const url = 'http://api.salo.com';
+
+  test('allow string as query field', () => {
+    createJsonMutation({
+      request: { url, method: 'GET', query: 'foo[]=1&foo[]=2&foo[]=3' },
+      response,
+    });
+
+    createJsonMutation({
+      request: {
+        url,
+        method: 'GET',
+        query: () => 'foo[]=1&foo[]=2&foo[]=3',
+      },
+      response,
+    });
+
+    createJsonMutation({
+      request: {
+        url,
+        method: 'GET',
+        query: { source: createStore(1), fn: () => 'foo[]=1&foo[]=2&foo[]=3' },
+      },
+      response,
+    });
+  });
+});
diff --git a/packages/core/src/query/__tests__/create_headless_query.test.ts b/packages/core/src/query/__tests__/create_headless_query.test.ts
index f3c54d0..dab1f8c 100644
--- a/packages/core/src/query/__tests__/create_headless_query.test.ts
+++ b/packages/core/src/query/__tests__/create_headless_query.test.ts
@@ -324,6 +324,6 @@ describe('core/createHeadlessQuery enabled', () => {
     expect(scope.getState(query.$data)).toBeNull();
     expect(scope.getState(query.$error)).toBeNull();
     expect(scope.getState(query.$status)).toBe('initial');
-    expect(scope.getState(query.$stale)).toBeFalsy();
+    expect(scope.getState(query.$stale)).toBeTruthy();
   });
 });
diff --git a/packages/core/src/query/__tests__/create_json_query.query.test-d.ts b/packages/core/src/query/__tests__/create_json_query.query.test-d.ts
new file mode 100644
index 0000000..f32fe32
--- /dev/null
+++ b/packages/core/src/query/__tests__/create_json_query.query.test-d.ts
@@ -0,0 +1,38 @@
+import { describe, test } from 'vitest';
+
+import { createJsonQuery } from '../create_json_query';
+import { Contract } from '../../contract/type';
+import { createStore } from 'effector';
+
+describe('createJsonQuery query', () => {
+  const response = {
+    contract: {} as Contract<unknown, unknown>,
+    mapData: <T>({ result }: { result: T }) => result,
+  };
+  const url = 'http://api.salo.com';
+
+  test('allow string as query field', () => {
+    createJsonQuery({
+      request: { url, method: 'GET', query: 'foo[]=1&foo[]=2&foo[]=3' },
+      response,
+    });
+
+    createJsonQuery({
+      request: {
+        url,
+        method: 'GET',
+        query: () => 'foo[]=1&foo[]=2&foo[]=3',
+      },
+      response,
+    });
+
+    createJsonQuery({
+      request: {
+        url,
+        method: 'GET',
+        query: { source: createStore(1), fn: () => 'foo[]=1&foo[]=2&foo[]=3' },
+      },
+      response,
+    });
+  });
+});
diff --git a/packages/typed-contracts/src/__tests__/contract.test-d.ts b/packages/typed-contracts/src/__tests__/contract.test-d.ts
new file mode 100644
index 0000000..bac9cc1
--- /dev/null
+++ b/packages/typed-contracts/src/__tests__/contract.test-d.ts
@@ -0,0 +1,16 @@
+import { describe, expectTypeOf } from 'vitest';
+import { string } from 'typed-contracts';
+
+import { typedContract } from '../contract';
+
+describe('runtypeContract', () => {
+  const contract = typedContract(string);
+
+  const smth: unknown = null;
+
+  if (contract.isData(smth)) {
+    expectTypeOf(smth).toEqualTypeOf<string>();
+    // @ts-expect-error smth is string
+    expectTypeOf(smth).toEqualTypeOf<number>();
+  }
+});
diff --git a/packages/typed-contracts/src/__tests__/contract.test.ts b/packages/typed-contracts/src/__tests__/contract.test.ts
new file mode 100644
index 0000000..bcf3dd4
--- /dev/null
+++ b/packages/typed-contracts/src/__tests__/contract.test.ts
@@ -0,0 +1,81 @@
+import { string, object, number, array } from 'typed-contracts';
+import { describe, test, expect } from 'vitest';
+
+import { typedContract } from '../contract';
+
+describe('ioTsContract', () => {
+  test('interprets invalid response as error', () => {
+    const contract = typedContract(string);
+
+    const data = 2;
+
+    expect(contract.isData(data)).toBe(false);
+
+    expect(contract.getErrorMessages(data)).toMatchInlineSnapshot(`
+      [
+        "\`value\` must be string, but 2 (number) given",
+      ]
+    `);
+  });
+
+  test('uses readable error for complex data structuire', () => {
+    const contract = typedContract(object({ age: number, name: string }));
+
+    const data = { age: 'not a number', name: 11 };
+
+    expect(contract.isData(data)).toBe(false);
+    expect(contract.getErrorMessages(data)).toMatchInlineSnapshot(`
+      [
+        "\`value.age\` must be number, but \\"not a number\\" (string) given",
+        "\`value.name\` must be string, but 11 (number) given",
+      ]
+    `);
+  });
+
+  test('uses readable error for nested data structuire', () => {
+    const contract = typedContract(
+      object({ user: object({ age: number, name: string }) })
+    );
+
+    const data = { user: { age: 'not a number', name: 11 } };
+
+    expect(contract.isData(data)).toBe(false);
+    expect(contract.getErrorMessages(data)).toMatchInlineSnapshot(`
+      [
+        "\`value.user.age\` must be number, but \\"not a number\\" (string) given",
+        "\`value.user.name\` must be string, but 11 (number) given",
+      ]
+    `);
+  });
+
+  test('uses readable error for arrays in data structuire', () => {
+    const contract = typedContract(
+      object({ users: array(object({ age: number, name: string })) })
+    );
+
+    const data = {
+      users: [
+        // ignore correct value
+        { age: 25, name: 'Igor' },
+        { age: 'eleven', name: 11 },
+      ],
+    };
+
+    expect(contract.isData(data)).toBe(false);
+    expect(contract.getErrorMessages(data)).toMatchInlineSnapshot(`
+      [
+        "\`value.users[1].age\` must be number, but \\"eleven\\" (string) given",
+        "\`value.users[1].name\` must be string, but 11 (number) given",
+      ]
+    `);
+  });
+
+  test('passes valid data', () => {
+    const contract = typedContract(string);
+
+    const data = 'foo';
+
+    expect(contract.isData(data)).toBe(true);
+    expect(contract.getErrorMessages(data)).toEqual([]);
+  });
+});