Skip to content

[Feature Request] Naming consistency, request interceptors, and per-collection type files #57

@WardenCommander

Description

@WardenCommander

[Feature Request] Naming consistency, request interceptors, and per-collection type files

Overview

Three related improvements to the generated client and generator output:

  1. Naming consistencyupload.destroy vs delete, authentication vs auth
  2. Request/response interceptors — hook into the request lifecycle for token
    management, logging, and global error handling
  3. Per-collection type files — split the single types.ts into one file per
    collection for better IDE performance, faster incremental builds, and readable diffs

1. Naming Consistency

1.1 upload.destroy vs upload.delete

UploadAPI is the only API across the entire generated client that uses destroy
instead of delete. Every CollectionAPI uses delete:

// Every collection — consistent
await client.collection.delete(documentId);

// UploadAPI — breaks the pattern
await client.upload.destroy(id); // ← only method across the whole client that uses this name

destroy is the internal Strapi controller action name
(plugin::upload.controllers.content-api.destroy) and arguably should not surface in
the public client API, which otherwise has no awareness of internal controller naming.

Open for discussion: whether delete is the right name, or whether there is a
reason to keep destroy (e.g. to signal that the operation is permanent and removes
the file from storage, not just a soft delete).


1.2 authentication vs auth

The auth namespace is the only property on StrapiClient that uses a long-form name.
Most SDKs and clients in the ecosystem use the shorter auth:

// Current
await client.authentication.login({ identifier, password });
await client.authentication.me();

// Shorter alternative
await client.auth.login({ identifier, password });
await client.auth.me();

Open for discussion: whether authentication should be replaced by auth,
kept alongside it, or left as-is. If renamed, keeping authentication as a
non-breaking alias is straightforward:

/** @deprecated Use `auth` instead. */
get authentication() {
  return this.auth;
}

This way existing consumers continue to work without changes while new code uses
the shorter form.


2. Request/Response Interceptors

Interceptors allow consumers to hook into the request lifecycle without subclassing
or patching the client. This is the most commonly requested pattern across HTTP
client libraries and covers use cases that cannot be solved at the call site.

Proposed StrapiClientConfig additions

export interface RequestConfig {
  url:     string;
  method:  string;
  headers: Record<string, string>;
  body?:   string | FormData;
}

export interface StrapiClientConfig {
  baseURL:         string;
  token?:          string;
  fetch?:          typeof fetch;
  debug?:          boolean;
  credentials?:    RequestCredentials;
  timeout?:        number;
  validateSchema?: boolean;

  // --- new ---

  /**
   * Called before every request. Return a modified config to change headers,
   * URL, or body. Async — safe to call token refresh or storage reads here.
   */
  onRequest?: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;

  /**
   * Called after every successful (2xx) response before the body is parsed.
   * Use for logging, metrics, or response transformation.
   */
  onResponse?: (response: Response, config: RequestConfig) => Response | Promise<Response>;

  /**
   * Called when a request throws a StrapiError or StrapiConnectionError.
   * Must always re-throw or redirect — returning without throwing is a runtime error.
   */
  onError?: (
    error: StrapiError | StrapiConnectionError,
    config: RequestConfig
  ) => never | Promise<never>;
}

Use case 1 — Dynamic token injection (most common)

The primary motivation: reading the token from any async source at request time
rather than managing it imperatively via setToken().

const client = new StrapiClient({
  baseURL: 'https://api.example.com',

  onRequest: async (config) => {
    const token = await getTokenFromStorage(); // SecureStore, AsyncStorage, keychain, etc.
    if (!token) return config;
    return {
      ...config,
      headers: { ...config.headers, Authorization: `Bearer ${token}` },
    };
  },
});

This eliminates the need to call setToken() on login/logout/restore and removes
any possibility of the client holding a stale token. The token is read from the
authoritative source on every request.

Note: Libraries that manage their own query/request lifecycle (such as query
caching libraries) often have their own retry and error handling mechanisms. In
those setups, onError on the client level can still be useful for global
side-effects (clearing storage, redirecting), while per-request error handling
is left to the calling layer.

Use case 2 — Logging and metrics

const client = new StrapiClient({
  baseURL: 'https://api.example.com',

  onRequest: async (config) => {
    console.log(`→ ${config.method} ${config.url}`);
    return config;
  },

  onResponse: async (response, config) => {
    console.log(`← ${response.status} ${config.url}`);
    return response;
  },
});

Use case 3 — Global 401 handling

const client = new StrapiClient({
  baseURL: 'https://api.example.com',

  onError: async (error) => {
    if (isStrapiErrorOf(error, 'UnauthorizedError')) {
      await clearStoredToken();
      // redirect to login, dispatch logout action, etc.
    }
    throw error; // always re-throw
  },
});

Integration in BaseAPI.request

protected async request<R>(
  url: string,
  options: RequestInit = {},
  nextOptions?: NextOptions,
  errorPrefix = 'Strapi API'
): Promise<R> {

  // Build headers as today...

  // 1. onRequest — modify config before sending
  let requestConfig: RequestConfig = {
    url,
    method: options.method ?? 'GET',
    headers,
    body: options.body as any,
  };

  if (this.config.onRequest) {
    requestConfig  = await this.config.onRequest(requestConfig);
    url            = requestConfig.url;
    options.body   = requestConfig.body;
    Object.assign(headers, requestConfig.headers);
  }

  let response: Response;
  try {
    response = await fetchFn(url, fetchOptions);
  } catch (networkError) {
    const connError = new StrapiConnectionError(...);
    if (this.config.onError) {
      await this.config.onError(connError, requestConfig);
    }
    throw connError;
  }

  // 2. onResponse — inspect/transform before parsing body
  if (response.ok && this.config.onResponse) {
    response = await this.config.onResponse(response, requestConfig);
  }

  if (!response.ok) {
    const strapiError = new StrapiError(...);
    if (this.config.onError) {
      await this.config.onError(strapiError, requestConfig);
    }
    throw strapiError;
  }

  // parse and return as today...
}

3. Per-Collection Type Files

Current output

The generator emits two files regardless of schema size:

generated/
├── types.ts     // 4 000 – 10 000+ lines: all entities, inputs, filters,
│                // payloads, populate params, components — everything
└── client.ts

As the schema grows types.ts becomes difficult to navigate, produces large
all-or-nothing diffs on any schema change, and puts the full type resolution
burden on the TypeScript language server for every file that imports even a
single type.

Proposed output structure

generated/
├── index.ts                  // barrel re-export — fully backwards compatible
├── client.ts                 // unchanged, imports from per-collection files
├── base.ts                   // shared primitives: StrapiID, RelationInput,
│                             // MediaFile, BlocksContent, filter operators, etc.
└── collections/
    ├── {collection-a}.ts     // Entity, EntityInput, EntityFilters,
    ├── {collection-b}.ts     // EntityPopulateParam, EntityGetPayload
    ├── {collection-c}.ts
    ├── ...                   // one file per collection type and single type
    └── components/
        ├── {group-a}.ts      // component interfaces grouped by Strapi namespace
        └── {group-b}.ts

index.ts — zero breaking changes

// generated/index.ts
export * from './base';
export * from './collections/collection-a';
export * from './collections/collection-b';
// ... one line per collection

All existing imports that reference "./generated/types" continue to work
without any changes. Consumers can optionally start importing directly from
per-collection files for faster local type resolution.

What each collection file contains

// collections/{collection}.ts

import type { RelationInput, MediaFile } from '../base';
import type { OtherEntityGetPayload, OtherEntityFilters } from './other-entity';

// 1. Entity read type
export interface Entity { ... }

// 2. Input type (create / update)
export interface EntityInput { ... }

// 3. Filter type
export interface EntityFilters extends LogicalOperators<EntityFilters> { ... }

// 4. Populate param
export type EntityPopulateParam = { ... }

// 5. GetPayload with populate inference
export type EntityGetPayload<P extends { ... }> = ...

Benefits

IDE performance — the TypeScript language server loads only the files
relevant to the currently open file. Editing a screen that uses one collection
no longer causes the LSP to parse thousands of lines of unrelated types.

Faster incremental builds — when only one collection's schema changes, only
its file is rewritten. All other files are untouched and the TypeScript
incremental cache (tsconfig.tsbuildinfo) stays valid.

Readable diffs — a schema change to a single collection produces a diff in
that collection's file only, not buried in a 10 000-line file with no context.

Monorepo / CODEOWNERS — per-collection files allow file-level ownership rules:

# .github/CODEOWNERS
generated/collections/collection-a.ts  @team-a
generated/collections/collection-b.ts  @team-b

Proposed CLI flag

# Default — existing single-file behaviour (non-breaking)
strapi-typed-client generate

# Opt in to per-collection splitting
strapi-typed-client generate --split-types

Once the pattern is validated --split-types could become the default in a
future major version, with --single-file as the opt-out.


Summary

# Change Breaking Effort
1.1 upload.destroyupload.delete (open to discuss) ⚠️ If renamed Trivial
1.2 auth alias alongside authentication (open to discuss) No — additive alias Trivial
2 onRequest / onResponse / onError interceptors No — additive config Low
3 Per-collection type files (--split-types flag) No — barrel keeps all imports valid Medium

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions