Thanks to visit codestin.com
Credit goes to github.com

Skip to content

bodySerializer not working #2439

@esau-morais

Description

@esau-morais

openapi-fetch version

^0.14.0

Description

import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies';
import createFetchClient from 'openapi-fetch';
import type { ClientOptions, Middleware } from 'openapi-fetch';
import createClient from 'openapi-react-query';

import { getLogger, getCorrelationId, createRequestLogger } from '@/lib/logger';

import type { paths } from './api-schema';

interface CsrfTokenCache {
  token: string;
  expiresAt: number;
}

let csrfTokenCache: CsrfTokenCache | null = null;
let csrfTokenPromise: Promise<string | null> | null = null;

const CSRF_REQUIRED_PATHS = [
  '/api/login',
  '/api/signup',
  '/api/logout',
  '/api/password-reset',
  '/api/change-password'
];

function requiresCsrfToken(url: string): boolean {
  return CSRF_REQUIRED_PATHS.some((path) => url.includes(path));
}

async function fetchCsrfToken(): Promise<string | null> {
  if (csrfTokenCache && Date.now() < csrfTokenCache.expiresAt) {
    return csrfTokenCache.token;
  }

  if (csrfTokenPromise) {
    return csrfTokenPromise;
  }

  const logger = getLogger();
  csrfTokenPromise = (async () => {
    try {
      logger.debug('Fetching CSRF token');
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/csrf-token`,
        { credentials: 'include' }
      );
      const data = await response.json();

      csrfTokenCache = {
        token: data.csrfToken,
        expiresAt: Date.now() + 15 * 60 * 1000
      };

      logger.debug('CSRF token cached successfully');
      return data.csrfToken;
    } catch (error) {
      logger.error({ error }, 'Failed to fetch CSRF token');
      return null;
    } finally {
      csrfTokenPromise = null;
    }
  })();

  return csrfTokenPromise;
}

const createServerAuthMiddleware = (
  cookieStore: ReadonlyRequestCookies
): Middleware => ({
  async onRequest({ request }) {
    const correlationId = getCorrelationId(request.headers);
    const logger = createRequestLogger(correlationId);

    request.headers.set('Accept', 'application/json');
    request.headers.set('X-Correlation-ID', correlationId);

    const accessToken = cookieStore.get('accessToken')?.value;
    const needsCsrf = requiresCsrfToken(request.url);
    let csrfToken = cookieStore.get('csrftoken')?.value;
    console.info({ contentType: request.headers.get('Content-Type') });

    logger.debug(
      {
        method: request.method,
        url: request.url,
        hasAuth: !!accessToken,
        hasCsrf: !!csrfToken,
        needsCsrf,
        contentType: request.headers.get('Content-Type')
      },
      'Server API request'
    );

    if (accessToken) {
      request.headers.set('Authorization', `Bearer ${accessToken}`);
    }

    if (needsCsrf) {
      if (!csrfToken) csrfToken = (await fetchCsrfToken()) || '';
      request.headers.set('X-CSRFToken', csrfToken);
    }

    return request;
  },
  onResponse: ({ response, request }) => {
    const correlationId = request.headers.get('X-Correlation-ID') || 'unknown';
    const logger = createRequestLogger(correlationId);

    logger.debug(
      {
        status: response.status,
        statusText: response.statusText,
        url: response.url
      },
      'Server API response'
    );

    if (!response.ok) {
      logger.warn(
        {
          status: response.status,
          statusText: response.statusText,
          url: response.url
        },
        'Server API error response'
      );
    }

    return response;
  }
});

const baseConfig = {
  baseUrl:
    process.env.NEXT_PUBLIC_API_BASE_URL',
  cache: 'no-cache',
  credentials: 'include'
} satisfies ClientOptions;

export function createServerApiClient(cookieStore: ReadonlyRequestCookies) {
  const serverFetchClient = createFetchClient<paths>(baseConfig);
  serverFetchClient.use(createServerAuthMiddleware(cookieStore));
  return {
    fetchClient: serverFetchClient,
    queryClient: createClient(serverFetchClient)
  };
}
import { createSafeActionClient } from 'next-safe-action';

const actionClient = createSafeActionClient();

export const replyImageAction = actionClient
  .inputSchema(imageReplySchema)
  .action(async ({ parsedInput }) => {
    const cookieStore = await cookies();

    const { data, error } = await createServerApiClient(
      cookieStore
    ).fetchClient.POST('/api/quiz/replies/images', {
      body: {
        user_id: parsedInput.user_id?.toString() || '',
        session_guid: parsedInput.session_guid,
        question_id: parsedInput.question_id.toString(),
        file: parsedInput.file
      },
      bodySerializer(body) {
        const formData = new FormData();

        formData.append('user_id', body.user_id);
        formData.append('session_guid', body.session_guid);
        formData.append('question_id', body.question_id);
        formData.append('file', body.file);

        return formData;
      }
    });

    if (error) {
      throw new Error(`Failed to submit image reply: ${JSON.stringify(error)}`);
    }

    return data;
  });

parsedInput, body and formData keys and values are logged however it returns the following error:

{"level":40,"levelLabel":"warn","time":"2025-09-12T23:47:33.626Z","pid":1,"name":"cosmetrics-ai","correlationId":"43f13baf-272c-4797-a81a-9c
ebf3627fd7","status":422,"statusText":"Unprocessable Entity","url":"","msg":"Server API 
error response"}
{
  error: '{"detail":[{"type":"missing","loc":["form","question_id"],"msg":"Field required"},{"type":"missing","loc":["form","user_id"],"msg"
:"Field required"},{"type":"missing","loc":["form","session_guid"],"msg":"Field required"},{"type":"missing","loc":["file","file"],"msg":"Fi
eld required"}]}'
}

this only ocurrs when running with Dockerfile. however when running with development server (next dev) it does not occur.

# Build stage
FROM node:20-alpine AS builder

# Install pnpm globally
RUN npm install -g pnpm

# Set working directory
WORKDIR /app

# Copy package files
COPY package.json pnpm-lock.yaml ./

# Install dependencies
RUN pnpm install --frozen-lockfile

# Copy source code
COPY . .

# Set build argument for API base URL
ARG NEXT_PUBLIC_API_BASE_URL=
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL

# Build the Next.js application
RUN pnpm build

# Production stage
FROM gcr.io/distroless/nodejs20-debian12 AS runner

# Set working directory
WORKDIR /app

# Copy standalone output from builder stage
COPY --from=builder --chown=65532:65532 /app/.next/standalone ./
COPY --from=builder --chown=65532:65532 /app/.next/static ./.next/static
COPY --from=builder --chown=65532:65532 /app/public ./public

# Distroless images run as non-root user by default (65532:65532)
# Expose port
EXPOSE 3000

# Start the application
CMD ["server.js"]

Reproduction

WIP

Expected result

FormData should be sent as expected

Extra

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingopenapi-fetchRelevant to the openapi-fetch library

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions