서버 게시글
This commit is contained in:
245
GameServer/wwwroot/js/api.js
Normal file
245
GameServer/wwwroot/js/api.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// api.js — 서버 REST API 얇은 래퍼
|
||||
// 모든 응답 본문은 ApiResponse<T> ( data / count / message ) 형태다.
|
||||
// 성공하면 Data 를 벗겨서 돌려주고, 실패하면 ApiError 를 던진다.
|
||||
|
||||
const BASE = '/api/portfolio';
|
||||
|
||||
/** 서버가 2xx 가 아닌 응답을 줬을 때 던지는 오류 */
|
||||
export class ApiError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
/** 상태 코드별 한국어 기본 메시지 (서버 message 가 없을 때만 사용) */
|
||||
function fallbackMessage(status) {
|
||||
if (status === 0) return '서버에 연결할 수 없습니다. 네트워크를 확인해 주세요.';
|
||||
if (status === 400) return '요청 내용이 올바르지 않습니다.';
|
||||
if (status === 401) return '로그인이 필요합니다.';
|
||||
if (status === 403) return '권한이 없습니다.';
|
||||
if (status === 404) return '요청한 데이터를 찾을 수 없습니다.';
|
||||
if (status === 409) return '이미 존재하거나 처리할 수 없는 요청입니다.';
|
||||
if (status === 413) return '파일 용량이 너무 큽니다.';
|
||||
if (status === 429) return '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.';
|
||||
if (status === 503) return '서비스를 사용할 수 없습니다.';
|
||||
if (status >= 500) return '서버 오류가 발생했습니다.';
|
||||
return '요청을 처리하지 못했습니다.';
|
||||
}
|
||||
|
||||
/** 204 / 빈 본문 / JSON 이 아닌 본문에서도 절대 throw 하지 않는 파서 */
|
||||
async function readBody(res) {
|
||||
if (res.status === 204 || res.status === 205) return null;
|
||||
let text = '';
|
||||
try {
|
||||
text = await res.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!text || !text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
// JSON 이 아니면 원문을 그대로 메시지 후보로 넘긴다.
|
||||
return { message: text };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 크기 초과처럼 ASP.NET 이 우리 형식(ApiResponse)을 거치지 않고 돌려주는
|
||||
* 영문 오류를 한국어로 바꾼다. 해당하지 않으면 빈 문자열.
|
||||
*/
|
||||
function translateFrameworkError(body) {
|
||||
let text = '';
|
||||
try {
|
||||
text = JSON.stringify(body);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (/Request body too large|request body size/i.test(text)) {
|
||||
const m = /(\d{6,})\s*bytes/.exec(text);
|
||||
const mb = m ? Math.floor(Number(m[1]) / (1024 * 1024)) : 0;
|
||||
return mb > 0
|
||||
? '파일이 너무 큽니다. 최대 ' + mb + 'MB 까지 올릴 수 있습니다.'
|
||||
: '파일이 너무 큽니다.';
|
||||
}
|
||||
if (/Multipart body length limit/i.test(text)) {
|
||||
return '파일이 너무 큽니다.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 응답 본문에서 사람이 읽을 메시지를 고른다. ApiResponse / ProblemDetails 모두 지원 */
|
||||
function pickMessage(body) {
|
||||
if (!body || typeof body !== 'object') return '';
|
||||
|
||||
// 프레임워크가 직접 낸 영문 오류를 먼저 걸러 낸다.
|
||||
const translated = translateFrameworkError(body);
|
||||
if (translated) return translated;
|
||||
|
||||
const candidates = [body.message, body.Message, body.detail, body.title, body.error];
|
||||
for (const value of candidates) {
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** ApiResponse 래퍼에서 Data 만 꺼낸다. 래퍼가 아니면 그대로 돌려준다. */
|
||||
function unwrapData(body) {
|
||||
if (!body || typeof body !== 'object') return body;
|
||||
if ('data' in body) return body.data;
|
||||
if ('Data' in body) return body.Data;
|
||||
return body;
|
||||
}
|
||||
|
||||
/** ApiResponse 의 Count 를 꺼낸다. 없으면 null */
|
||||
function unwrapCount(body) {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
if (typeof body.count === 'number') return body.count;
|
||||
if (typeof body.Count === 'number') return body.Count;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 요청 함수. 성공 시 파싱된 본문(래핑된 상태)을 그대로 돌려준다.
|
||||
* @param {string} path
|
||||
* @param {{method?:string, body?:any, form?:FormData}} [options]
|
||||
*/
|
||||
async function request(path, options = {}) {
|
||||
const method = options.method || 'GET';
|
||||
const init = {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' }
|
||||
};
|
||||
|
||||
if (options.form) {
|
||||
// multipart 는 브라우저가 boundary 를 붙여야 하므로 Content-Type 을 지정하지 않는다.
|
||||
init.body = options.form;
|
||||
} else if (options.body !== undefined) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(path, init);
|
||||
} catch {
|
||||
throw new ApiError(0, fallbackMessage(0));
|
||||
}
|
||||
|
||||
const body = await readBody(res);
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, pickMessage(body) || fallbackMessage(res.status));
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 요청 후 Data 만 벗겨서 돌려준다. */
|
||||
async function requestData(path, options) {
|
||||
const body = await request(path, options);
|
||||
return unwrapData(body);
|
||||
}
|
||||
|
||||
/** 쿼리스트링을 만든다. null/undefined/빈문자열 값은 제외. */
|
||||
function queryString(params) {
|
||||
const usp = new URLSearchParams();
|
||||
for (const key of Object.keys(params || {})) {
|
||||
const value = params[key];
|
||||
if (value === null || value === undefined || value === '') continue;
|
||||
usp.set(key, String(value));
|
||||
}
|
||||
const text = usp.toString();
|
||||
return text ? '?' + text : '';
|
||||
}
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
/** 로그인 상태 조회. 401 을 던지지 않는다. → { authenticated: bool } */
|
||||
me() {
|
||||
return requestData(BASE + '/auth/me');
|
||||
},
|
||||
/** 관리자 로그인 */
|
||||
login(password) {
|
||||
return requestData(BASE + '/auth/login', { method: 'POST', body: { password } });
|
||||
},
|
||||
/** 관리자 로그아웃 */
|
||||
logout() {
|
||||
return requestData(BASE + '/auth/logout', { method: 'POST', body: {} });
|
||||
}
|
||||
},
|
||||
|
||||
categories: {
|
||||
/** 카테고리 전체 목록 → CategoryDto[] */
|
||||
async list() {
|
||||
const data = await requestData(BASE + '/categories');
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
create(dto) {
|
||||
return requestData(BASE + '/categories', { method: 'POST', body: dto });
|
||||
},
|
||||
update(no, dto) {
|
||||
return requestData(BASE + '/categories/' + encodeURIComponent(no), { method: 'PUT', body: dto });
|
||||
},
|
||||
remove(no) {
|
||||
return requestData(BASE + '/categories/' + encodeURIComponent(no), { method: 'DELETE' });
|
||||
}
|
||||
},
|
||||
|
||||
posts: {
|
||||
/**
|
||||
* 게시물 목록. Count 는 페이지 크기가 아니라 전체 개수다.
|
||||
* @returns {Promise<{items: Array, total: number}>}
|
||||
*/
|
||||
async list(opts = {}) {
|
||||
const category = opts.category && opts.category !== 'all' ? opts.category : '';
|
||||
const qs = queryString({ category, page: opts.page, size: opts.size });
|
||||
const body = await request(BASE + '/posts' + qs);
|
||||
const data = unwrapData(body);
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
const count = unwrapCount(body);
|
||||
return { items, total: typeof count === 'number' ? count : items.length };
|
||||
},
|
||||
/** 게시물 상세 → PostDetailDto */
|
||||
get(no) {
|
||||
return requestData(BASE + '/posts/' + encodeURIComponent(no));
|
||||
},
|
||||
create(dto) {
|
||||
return requestData(BASE + '/posts', { method: 'POST', body: dto });
|
||||
},
|
||||
update(no, dto) {
|
||||
return requestData(BASE + '/posts/' + encodeURIComponent(no), { method: 'PUT', body: dto });
|
||||
},
|
||||
remove(no) {
|
||||
return requestData(BASE + '/posts/' + encodeURIComponent(no), { method: 'DELETE' });
|
||||
}
|
||||
},
|
||||
|
||||
uploads: {
|
||||
/**
|
||||
* 업로드 한도와 허용 확장자.
|
||||
* @returns {Promise<{maxBytes:number, maxMegabytes:number, allowedExtensions:string[]}>}
|
||||
*/
|
||||
limit() {
|
||||
return requestData(BASE + '/uploads/limit');
|
||||
},
|
||||
/**
|
||||
* 이미지 업로드. 필드 이름은 'file', Content-Type 은 지정하지 않는다.
|
||||
* @param {File} file
|
||||
* @returns {Promise<{url:string, fileName:string, size:number}>}
|
||||
*/
|
||||
upload(file) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return requestData(BASE + '/uploads', { method: 'POST', form });
|
||||
},
|
||||
/** 업로드된 파일 삭제 (204 → null) */
|
||||
remove(url) {
|
||||
return requestData(BASE + '/uploads' + queryString({ url }), { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default api;
|
||||
Reference in New Issue
Block a user