-
-
Notifications
You must be signed in to change notification settings - Fork 573
Open
Labels
bugSomething isn't workingSomething isn't workingopenapi-fetchRelevant to the openapi-fetch libraryRelevant to the openapi-fetch library
Description
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
- I’m willing to open a PR (see CONTRIBUTING.md)
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't workingopenapi-fetchRelevant to the openapi-fetch libraryRelevant to the openapi-fetch library