Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4

- name: Login to GHCR
run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push Docker image
run: |
Expand All @@ -26,15 +33,18 @@ jobs:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: GHCR_PULL_TOKEN
script: |
set -e
cd ~/Project_RoboSenseAPI
git pull origin main
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
echo "$GHCR_PULL_TOKEN" | docker login ghcr.io -u jeonggihun --password-stdin
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
sleep 15
curl -f http://localhost/health || (echo "Health check failed, rolling back" && docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d --force-recreate && exit 1)
env:
GHCR_PULL_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Verify deployment
uses: appleboy/ssh-action@v1
Expand Down
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from fastapi import FastAPI
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.cors import CORSMiddleware
from app.routes import sensor_routes, robot_routes, stats_routes, admin_routes
from app.routes import sensor_routes, robot_routes, stats_routes, admin_routes, demo_routes
from app.database import engine, Base, init_asyncpg_pool, close_asyncpg_pool, get_asyncpg_pool
from app.middleware import RequestIDMiddleware
from app.logging_config import RequestIDFilter
Expand Down Expand Up @@ -77,6 +77,7 @@ async def lifespan(app: FastAPI):
app.include_router(robot_routes.router)
app.include_router(stats_routes.router)
app.include_router(admin_routes.router)
app.include_router(demo_routes.router)

@app.get("/", response_class=HTMLResponse)
def root() :
Expand Down
73 changes: 73 additions & 0 deletions app/routes/demo_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""랜딩페이지용 공개 조회 엔드포인트.

API Key 없이 접근 가능. **조회 전용**.
쓰기는 `/api/*` 에서만 가능 (서버-to-서버 통합은 API Key 필수).

왜 쓰기를 제공하지 않나:
- 익명 쓰기를 열면 봇·스크립트가 DB 오염 가능 (rate limit 없음)
- RoboSense는 로봇/IoT 기기 통합 API라 사람 로그인 개념이 없음
→ 쓰기 권한을 익명 브라우저 세션에 부여할 근거가 없음
- 조회만 공개하면 포트폴리오 시연 요구 + 보안 요구 둘 다 만족
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from datetime import datetime

from app.database import get_db
from app.models.robot import RobotResponse, RobotDetailResponse
from app.models.sensor import SensorListResponse, SensorResponse, FilteredSensorResponse
from app.routes import robot_routes, sensor_routes, stats_routes

router = APIRouter(prefix="/demo", tags=["랜딩페이지 공개 조회"])


@router.get("/robots", response_model=list[RobotResponse])
async def demo_list_robots(
status: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
return await robot_routes.robot_data_list(status=status, db=db, _key=None)


@router.get("/robots/{id}", response_model=RobotDetailResponse)
async def demo_get_robot(id: int, db: AsyncSession = Depends(get_db)):
return await robot_routes.robot_data_specific_list(id=id, db=db, _key=None)


@router.get("/sensors", response_model=SensorListResponse)
async def demo_list_sensors(
limit: int = Query(100, ge=1, le=1000),
robot_id: Optional[int] = None,
sensor_type: Optional[str] = None,
cursor_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
):
return await sensor_routes.check_filter_data(
limit=limit, robot_id=robot_id, sensor_type=sensor_type,
cursor_id=cursor_id, db=db, _key=None,
)


@router.get("/sensors/filtered", response_model=FilteredSensorResponse)
async def demo_filtered_sensors(robot_id: int, sensor_type: str, field: str, window_size: int):
return await sensor_routes.check_filter_sensor_data(
robot_id=robot_id, sensor_type=sensor_type,
field=field, window_size=window_size, _key=None,
)


@router.get("/sensors/{id}", response_model=SensorResponse)
async def demo_get_sensor(id: int, db: AsyncSession = Depends(get_db)):
return await sensor_routes.check_filter_specific_data(id=id, db=db, _key=None)


@router.get("/stats")
async def demo_stats(
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
db: AsyncSession = Depends(get_db),
):
return await stats_routes.get_stats(
start_time=start_time, end_time=end_time, db=db, _key=None,
)
13 changes: 7 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ <h1>RoboSense API</h1>
<div class="section-title">API 테스트</div>
<p class="section-desc">실제 서버에 요청을 보내고 응답을 확인하세요. 모든 기능은 로봇 등록 후 사용할 수 있습니다.</p>
<div class="flow-banner"><div class="icon">&#9888;</div><div><strong>Step 1 필수</strong> &#8212; 센서 데이터 수집, 필터링, 통계 등 모든 기능은 <strong>로봇 등록(POST /api/robots)</strong> 이후에만 사용할 수 있습니다.</div></div>
<div class="flow-banner" style="background:linear-gradient(135deg,rgba(99,102,241,.08),rgba(129,140,248,.08));border-color:rgba(99,102,241,.25)"><div class="icon">&#128274;</div><div><strong>인증 안내</strong> &#8212; <strong>조회(GET)</strong>는 공개 엔드포인트(<code>/demo/*</code>)로 키 없이 동작합니다. <strong>쓰기(POST/PUT/DELETE)</strong>는 <code>/api/*</code> + <code>X-API-Key</code> 헤더 필요 &#8212; 버튼 클릭 시 401이 정상 응답입니다.</div></div>
<div class="flow-steps">
<div class="flow-step"><div class="flow-step-num">1</div><h4>로봇 등록</h4><p>POST /api/robots</p></div><div class="flow-connector">&#8594;</div>
<div class="flow-step"><div class="flow-step-num">2</div><h4>센서 데이터 수집</h4><p>POST /api/sensors</p></div><div class="flow-connector">&#8594;</div>
Expand Down Expand Up @@ -332,8 +333,8 @@ <h5 style="margin-top:1.25rem;">Registered Robots</h5>
}).catch(function(e){showErr('resp-post-robot',e.message);});
}

function getRobots(){showLoad('resp-get-robots');var s=document.getElementById('robots-list-status').value;api('GET','/api/robots'+(s?'?status='+s:'')).then(function(r){showResp('resp-get-robots',r.status,r.data,r.ms);if(r.status===200&&Array.isArray(r.data)&&r.data.length){R=r.data.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(e){showErr('resp-get-robots',e.message);});}
function getRobotDetail(){showLoad('resp-get-robot-detail');api('GET','/api/robots/'+document.getElementById('robot-detail-id').value).then(function(r){showResp('resp-get-robot-detail',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-robot-detail',e.message);});}
function getRobots(){showLoad('resp-get-robots');var s=document.getElementById('robots-list-status').value;api('GET','/demo/robots'+(s?'?status='+s:'')).then(function(r){showResp('resp-get-robots',r.status,r.data,r.ms);if(r.status===200&&Array.isArray(r.data)&&r.data.length){R=r.data.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(e){showErr('resp-get-robots',e.message);});}
function getRobotDetail(){showLoad('resp-get-robot-detail');api('GET','/demo/robots/'+document.getElementById('robot-detail-id').value).then(function(r){showResp('resp-get-robot-detail',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-robot-detail',e.message);});}
function putRobot(){showLoad('resp-put-robot');var id=document.getElementById('robot-update-id').value;api('PUT','/api/robots/'+id,{status:document.getElementById('robot-update-status').value,battery_level:parseInt(document.getElementById('robot-update-battery').value)}).then(function(r){showResp('resp-put-robot',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-put-robot',e.message);});}
function postSensor(){showLoad('resp-post-sensor');try{api('POST','/api/sensors',JSON.parse(document.getElementById('sensor-body').value)).then(function(r){showResp('resp-post-sensor',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-post-sensor',e.message);});}catch(e){showErr('resp-post-sensor','Invalid JSON');}}

Expand Down Expand Up @@ -382,15 +383,15 @@ <h5 style="margin-top:1.25rem;">Registered Robots</h5>
}).catch(function(){autoErrN++;document.getElementById('autoErrors').textContent=autoErrN;});
}

function getSensors(){showLoad('resp-get-sensors');var p=[],rid=parseInt(document.getElementById('sensor-list-robot-id').value),st=document.getElementById('sensor-list-type').value,lim=document.getElementById('sensor-list-limit').value;if(rid&&rid>0)p.push('robot_id='+rid);else if(rid&&rid<=0){showErr('resp-get-sensors','Robot ID는 1 이상이어야 합니다');return;}if(st)p.push('sensor_type='+st);if(lim)p.push('limit='+lim);api('GET','/api/sensors'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-sensors',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-sensors',e.message);});}
function getFiltered(){showLoad('resp-get-filtered');api('GET','/api/sensors/filtered?robot_id='+document.getElementById('filter-robot-id').value+'&sensor_type='+document.getElementById('filter-sensor-type').value+'&field='+document.getElementById('filter-field').value+'&window_size='+document.getElementById('filter-window').value).then(function(r){showResp('resp-get-filtered',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-filtered',e.message);});}
function getStats(){showLoad('resp-get-stats');var p=[],s=document.getElementById('stats-start').value,e=document.getElementById('stats-end').value;if(s)p.push('start_time='+new Date(s).toISOString());if(e)p.push('end_time='+new Date(e).toISOString());api('GET','/api/stats'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-stats',r.status,r.data,r.ms);}).catch(function(e2){showErr('resp-get-stats',e2.message);});}
function getSensors(){showLoad('resp-get-sensors');var p=[],rid=parseInt(document.getElementById('sensor-list-robot-id').value),st=document.getElementById('sensor-list-type').value,lim=document.getElementById('sensor-list-limit').value;if(rid&&rid>0)p.push('robot_id='+rid);else if(rid&&rid<=0){showErr('resp-get-sensors','Robot ID는 1 이상이어야 합니다');return;}if(st)p.push('sensor_type='+st);if(lim)p.push('limit='+lim);api('GET','/demo/sensors'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-sensors',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-sensors',e.message);});}
function getFiltered(){showLoad('resp-get-filtered');api('GET','/demo/sensors/filtered?robot_id='+document.getElementById('filter-robot-id').value+'&sensor_type='+document.getElementById('filter-sensor-type').value+'&field='+document.getElementById('filter-field').value+'&window_size='+document.getElementById('filter-window').value).then(function(r){showResp('resp-get-filtered',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-filtered',e.message);});}
function getStats(){showLoad('resp-get-stats');var p=[],s=document.getElementById('stats-start').value,e=document.getElementById('stats-end').value;if(s)p.push('start_time='+new Date(s).toISOString());if(e)p.push('end_time='+new Date(e).toISOString());api('GET','/demo/stats'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-stats',r.status,r.data,r.ms);}).catch(function(e2){showErr('resp-get-stats',e2.message);});}
function healthCheck(){showLoad('resp-health');api('GET','/health_check').then(function(r){showResp('resp-health',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-health',e.message);});}
function toLocal(d){return new Date(d.getTime()-d.getTimezoneOffset()*60000).toISOString().slice(0,16);}
var pageLoad=new Date();
document.getElementById('stats-start').value=toLocal(pageLoad);
document.getElementById('stats-end').value=toLocal(new Date(pageLoad.getTime()+3600000));
fetch(B+'/api/robots').then(function(r){return r.json();}).then(function(d){if(Array.isArray(d)&&d.length){R=d.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(){});
fetch(B+'/demo/robots').then(function(r){return r.json();}).then(function(d){if(Array.isArray(d)&&d.length){R=d.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(){});
</script>
</body>
</html>
2 changes: 1 addition & 1 deletion nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ http {
server fastapi-3:8000;
server fastapi-4:8000;
server fastapi-5:8000;
keepalive 10;
keepalive 16;
keepalive_timeout 5;
keepalive_requests 1000;
}
Expand Down
100 changes: 100 additions & 0 deletions test/test_demo_bff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""demo 프록시 라우트 테스트.

랜딩페이지용 공개 **조회 전용** 엔드포인트. 쓰기는 여전히 `/api/*`(API Key 필요).
"""
from app.models.db_models import Robot


# === 읽기 허용 ===

async def test_demo_get_robots_empty(client):
"""키 없이 GET /demo/robots 호출 가능"""
del client.headers["X-API-Key"]
response = await client.get("/demo/robots")
assert response.status_code == 200
assert response.json() == []


async def test_demo_get_robots_with_data(client, db_session):
"""GET /demo/robots 가 데이터 반환"""
db_session.add(Robot(name="Demo1", model="m1", status="active", battery_level=70))
await db_session.commit()

del client.headers["X-API-Key"]
response = await client.get("/demo/robots")
assert response.status_code == 200
assert len(response.json()) == 1


async def test_demo_get_sensors(client):
"""키 없이 GET /demo/sensors"""
del client.headers["X-API-Key"]
response = await client.get("/demo/sensors?limit=5")
assert response.status_code == 200


async def test_demo_stats(client):
"""키 없이 GET /demo/stats"""
del client.headers["X-API-Key"]
response = await client.get("/demo/stats")
assert response.status_code == 200


# === 쓰기 차단 ===

async def test_demo_post_robot_not_allowed(client):
"""POST /demo/robots 는 제공되지 않음 — 쓰기는 /api/* + API Key 필요"""
del client.headers["X-API-Key"]
response = await client.post(
"/demo/robots",
json={"name": "X", "model": "v1", "status": "active", "battery_level": 50},
)
assert response.status_code in (404, 405)


async def test_demo_put_robot_not_allowed(client, db_session):
"""PUT /demo/robots/{id} 도 제공되지 않음 (로봇 실존해도 경로 자체 없음)"""
db_session.add(Robot(name="R1", model="m", status="active", battery_level=50))
await db_session.commit()

del client.headers["X-API-Key"]
response = await client.put(
"/demo/robots/1",
json={"status": "inactive", "battery_level": 30},
)
assert response.status_code in (404, 405)


async def test_demo_post_sensor_not_allowed(client):
"""POST /demo/sensors 도 제공되지 않음"""
del client.headers["X-API-Key"]
response = await client.post(
"/demo/sensors",
json={"robot_id": 1, "timestamp": "2026-01-01T00:00:00", "sensors": []},
)
assert response.status_code in (404, 405)


async def test_demo_does_not_expose_reset(client):
"""DELETE /demo/reset 도 제공되지 않음"""
del client.headers["X-API-Key"]
response = await client.delete("/demo/reset")
assert response.status_code == 404


# === 회귀: /api/* 는 여전히 API Key 필수 ===

async def test_api_routes_still_require_key(client):
del client.headers["X-API-Key"]
response = await client.get("/api/robots")
assert response.status_code == 401
assert response.json()["error_code"] == "API_KEY_MISSING"


async def test_api_post_still_requires_key(client):
del client.headers["X-API-Key"]
response = await client.post(
"/api/robots",
json={"name": "X", "model": "v1", "status": "active", "battery_level": 50},
)
assert response.status_code == 401
Loading