graphql-upload 설치관련yarn add graphql-upload
yarn add -D @types/graphql-upload
tsconfig.json 에 다음 추가 "allowJs": true,
"maxNodeModuleJsDepth": 10,
.mjs 파일을 직접 import 해야함. (index.js가 없음)import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; 공홈 README.md 보면 이렇게 나와있음The npm package graphql-upload features optimal JavaScript module design. It doesn’t have a main index module, so use deep imports from the ECMAScript modules that are exported via the package.json field exports:
graphql-upload 사용 관련 이슈GraphQLModule 설정 관련// app.module.ts 또는 graphql.module.ts
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
uploads: {
maxFileSize: 10_000_000, // 10MB
maxFiles: 1,
},
}),
이렇게하면 안됨. 아마 uploads 라는 속성이 ApolloDirverConfig 안에 없다고 에러날 것임.
대신 main.ts에 이렇게 해줘야함.
app.use(graphqlUploadExpress({ maxFileSize: 10_000_000, maxFiles: 1 }));
graphql-upload 로부터 import 하기import { FileUpload, GraphQLUpload } from 'graphql-upload';
// ...
@Mutation(() => GraphqlBaseMenuResponseDto)
async createMenu(
@GqlUser('id') userId: string,
@Args('input') input: GraphqlCreateMenuRequestDto,
@Args({ name: 'file', type: () => GraphQLUpload }) file: FileUpload,
): Promise<GraphqlBaseMenuResponseDto> {
// ...
// 파일 저장
await new Promise((resolve, reject) =>
createReadStream()
.pipe(createWriteStream(uploadPath))
.on('finish', resolve)
.on('error', reject),
);
일단 import는 이렇게 해야함.
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { FileUpload } from 'graphql-upload/processRequest.mjs';
경로도 틀렸고, 그리고 processRequest.mjs 로부터 export 되는 FileUpload를 써야함.
createMenu()의 인자 쓰기imageFile는 input의 GraphqlCreateMenuRequestDto 에는 빼고, 별도의 imageFile 인자로 받아야 함. graphql-upload를 써야하는 것.{ nullable: true } 를 꼭 써야 함. async createMenu(
@GqlUser('id') userId: string,
@Args('input') input: GraphqlCreateMenuRequestDto,
@Args({ name: 'imageFile', type: () => GraphQLUpload, nullable: true }) imageFile?: FileUpload,
): Promise<GraphqlBaseMenuResponseDto> {...}
multipart/form-data 대응fetchGraphQL()은 JSON POST만 처리 가능multipart/form-data로 전송 필요fetchMultipartGraphQL 함수 구현 필요imageFile이 있을 경우 variables.imageFile = null 대입. 이유는,operations JSON 안에서 variables.imageFile = null 로 선언함: 플레이스홀더 역할
map 객체에서 "0": ["variables.imageFile"] 로 매핑
실제 "0" key에 파일을 넣어주면, 서버가 자동으로 null 자리에 파일 스트림을 주입
이렇게 하지 않고 file 을 그대로 넣으면,
JSON.stringify 할 때 오류 나거나,
서버가 스트림을 주입할 자리를 못 찾음
그리고 Apollo가 "Unexpected input type" 같은 에러를 냄
formData append 순서 중요!operations → map → 실제 파일(0) 순으로 append 해야 정상 동작Misordered multipart fields 에러 발생 시 순서 확인 필요axios.post() 호출할때 url은 ""로 비워두기. 왜냐하면 baseURL에 /graphql을 이미 넣어뒀으니까.import axiosInstanceGraphql from "@/common/axiosInstanceGraphql";
// multipart 대응 fetcher
export function fetchMultipartGraphQL(
operation: any,
variables: Record<string, any>
) {
const formData = new FormData();
console.log("[DEBUG] fetchMultipartGraphQL operation: ", operation);
console.log("[DEBUG] fetchMultipartGraphQL variables: ", variables);
const operations = JSON.stringify({
query: operation.text,
variables: {
...variables,
// 파일이 있을 경우 null로 치환
imageFile:
variables.imageFile instanceof File ? null : variables.imageFile,
},
});
formData.append("operations", operations);
const map: Record<string, string[]> = {};
if (variables.imageFile instanceof File) {
map["0"] = ["variables.imageFile"];
formData.append("map", JSON.stringify(map));
formData.append("0", variables.imageFile);
}
return axiosInstanceGraphql.post("", formData).then((res) => res.data);
}
This operation has been blocked as a potential Cross-Site Request Forgery (CSRF)axiosInstanceGraphql에서 Content-Type 헤더를 강제로 설정하고 있었음Content-Type을 명시하면 preflight 생략되어 CSRF 차단 대상이 됨Content-Type을 명시하지 않으면 브라우저가 자동 설정axiosInstanceGraphql.interceptors.request.use((config) => {
const isMultipart = config.data instanceof FormData;
if (isMultipart) {
delete config.headers["Content-Type"];
}
return config;
});
query가 누락될 경우 서버가 요청 거부GraphQL operations must contain a non-empty `query`operation.text가 반드시 operations.query에 포함되어야 함useCreateMenuMutation에 imageFile 추가기존 mutation 정의:
mutation useCreateMenuMutation($input: GraphqlCreateMenuRequestDto!) {
createMenu(input: $input) { ... }
}
이미지 파일 포함을 위해 아래와 같이 수정:
mutation useCreateMenuMutation($input: GraphqlCreateMenuRequestDto!, $imageFile: Upload) {
createMenu(input: $input, imageFile: $imageFile) { id name }
}
useCreateMenu.ts도 다음과 같이 수정:
commit({
variables: { input, imageFile },
onCompleted: (data) => { ... }
})
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true });
}
refetch가 안됨. 하...