22from typing import TYPE_CHECKING
33
44from dependency_injector .wiring import Provide , inject
5- from fastapi import APIRouter , Depends , HTTPException
5+ from fastapi import APIRouter , Depends
66from fastapi .params import Security
77from fastapi .security import APIKeyHeader
88from pydantic import UUID4
99from starlette .requests import Request
10- from starlette .responses import Response
10+ from starlette .responses import PlainTextResponse , Response
1111
1212from app .api .dto .Error import ErrorResponse
1313from app .api .dto .paste_dto import (
1919)
2020from app .config import config
2121from app .containers import Container
22+ from app .exceptions import PasteNotFoundError
2223from app .ratelimit import create_limit_resolver , get_exempt_key , limiter
2324from app .services .paste_service import PasteService
2425from app .utils .LRUMemoryCache import LRUMemoryCache
@@ -49,15 +50,18 @@ def set_cache(cache_instance: "RedisCache | LRUMemoryCache"):
4950@pastes_route .get (
5051 "/legacy/{paste_id}" ,
5152 responses = {404 : {"model" : ErrorResponse }, 200 : {"model" : LegacyPasteResponse }},
53+ summary = "Get legacy Hastebin-format paste" ,
54+ description = "Retrieve a paste stored in legacy Hastebin format by its ID." ,
5255)
5356@limiter .limit (create_limit_resolver (config , "get_paste_legacy" ), key_func = get_exempt_key )
5457@inject
55- async def get_paste (
58+ async def get_legacy_paste (
5659 request : Request ,
5760 paste_id : str ,
5861 paste_service : PasteService = Depends (Provide [Container .paste_service ]),
5962):
60- cached_result = await cache .get (paste_id )
63+ """Get a legacy Hastebin-format paste by ID."""
64+ cached_result = await cache .get (f"legacy:{ paste_id } " )
6165 if cached_result :
6266 cache_operations .labels (operation = "get" , result = "hit" ).inc ()
6367 return Response (
@@ -71,24 +75,14 @@ async def get_paste(
7175 cache_operations .labels (operation = "get" , result = "miss" ).inc ()
7276 paste_result = await paste_service .get_legacy_paste_by_name (paste_id )
7377 if not paste_result :
74- return Response (
75- ErrorResponse (
76- error = "legacy_paste_not_found" ,
77- message = f"Paste { paste_id } not found" ,
78- ).model_dump_json (),
79- status_code = 404 ,
80- headers = {
81- "Content-Type" : "application/json" ,
82- "Cache-Control" : "public, immutable" ,
83- },
84- )
85- paste_result = paste_result .model_dump_json ()
78+ raise PasteNotFoundError (paste_id )
8679
87- await cache .set (paste_id , paste_result , ttl = config .CACHE_TTL )
80+ paste_json = paste_result .model_dump_json ()
81+ await cache .set (f"legacy:{ paste_id } " , paste_json , ttl = config .CACHE_TTL )
8882 cache_operations .labels (operation = "set" , result = "success" ).inc ()
8983
9084 return Response (
91- paste_result ,
85+ paste_json ,
9286 headers = {
9387 "Content-Type" : "application/json" ,
9488 "Cache-Control" : f"public, max-age={ config .CACHE_TTL } " ,
@@ -99,15 +93,18 @@ async def get_paste(
9993@pastes_route .get (
10094 "/{paste_id}" ,
10195 responses = {404 : {"model" : ErrorResponse }, 200 : {"model" : PasteResponse }},
96+ summary = "Get paste by UUID" ,
97+ description = "Retrieve a paste by its UUID identifier." ,
10298)
10399@limiter .limit (create_limit_resolver (config , "get_paste" ), key_func = get_exempt_key )
104100@inject
105- async def get_paste (
101+ async def get_paste_by_uuid (
106102 request : Request ,
107103 paste_id : UUID4 ,
108104 paste_service : PasteService = Depends (Provide [Container .paste_service ]),
109105):
110- cached_result = await cache .get (paste_id )
106+ """Get a paste by its UUID."""
107+ cached_result = await cache .get (str (paste_id ))
111108 if cached_result :
112109 cache_operations .labels (operation = "get" , result = "hit" ).inc ()
113110 return Response (
@@ -121,42 +118,94 @@ async def get_paste(
121118 cache_operations .labels (operation = "get" , result = "miss" ).inc ()
122119 paste_result = await paste_service .get_paste_by_id (paste_id )
123120 if not paste_result :
124- return Response (
125- ErrorResponse (
126- error = "paste_not_found" ,
127- message = f"Paste { paste_id } not found" ,
128- ).model_dump_json (),
129- status_code = 404 ,
130- headers = {
131- "Content-Type" : "application/json" ,
132- },
133- )
134- paste_result = paste_result .model_dump_json ()
121+ raise PasteNotFoundError (str (paste_id ))
135122
136- await cache .set (paste_id , paste_result , ttl = config .CACHE_TTL )
123+ paste_json = paste_result .model_dump_json ()
124+ await cache .set (str (paste_id ), paste_json , ttl = config .CACHE_TTL )
137125 cache_operations .labels (operation = "set" , result = "success" ).inc ()
138126
139127 return Response (
140- paste_result ,
128+ paste_json ,
141129 headers = {
142130 "Content-Type" : "application/json" ,
143131 "Cache-Control" : f"public, max-age={ config .CACHE_TTL } " ,
144132 },
145133 )
146134
147135
148- @pastes_route .post ("" , response_model = CreatePasteResponse )
136+ @pastes_route .get (
137+ "/{paste_id}/raw" ,
138+ response_class = PlainTextResponse ,
139+ responses = {404 : {"model" : ErrorResponse }},
140+ summary = "Get raw paste content" ,
141+ description = "Retrieve only the raw text content of a paste. Useful for curl/wget users." ,
142+ )
143+ @limiter .limit (create_limit_resolver (config , "get_paste" ), key_func = get_exempt_key )
144+ @inject
145+ async def get_paste_raw (
146+ request : Request ,
147+ paste_id : UUID4 ,
148+ paste_service : PasteService = Depends (Provide [Container .paste_service ]),
149+ ):
150+ """
151+ Get raw paste content as plain text.
152+
153+ This endpoint returns only the paste content without any JSON wrapper,
154+ making it ideal for command-line tools like curl or wget.
155+
156+ Example usage:
157+ curl https://api.devbin.dev/pastes/{paste_id}/raw
158+ """
159+ # Check cache for raw content
160+ cache_key = f"raw:{ paste_id } "
161+ cached_content = await cache .get (cache_key )
162+ if cached_content :
163+ cache_operations .labels (operation = "get" , result = "hit" ).inc ()
164+ return PlainTextResponse (
165+ content = cached_content ,
166+ headers = {"Cache-Control" : f"public, max-age={ config .CACHE_TTL } " },
167+ )
168+
169+ cache_operations .labels (operation = "get" , result = "miss" ).inc ()
170+ paste_result = await paste_service .get_paste_by_id (paste_id )
171+ if not paste_result :
172+ raise PasteNotFoundError (str (paste_id ))
173+
174+ content = paste_result .content or ""
175+
176+ # Cache the raw content
177+ await cache .set (cache_key , content , ttl = config .CACHE_TTL )
178+ cache_operations .labels (operation = "set" , result = "success" ).inc ()
179+
180+ return PlainTextResponse (
181+ content = content ,
182+ headers = {"Cache-Control" : f"public, max-age={ config .CACHE_TTL } " },
183+ )
184+
185+
186+ @pastes_route .post (
187+ "" ,
188+ response_model = CreatePasteResponse ,
189+ summary = "Create a new paste" ,
190+ description = "Create a new paste with the provided content and metadata." ,
191+ )
149192@limiter .limit (create_limit_resolver (config , "create_paste" ), key_func = get_exempt_key )
150193@inject
151194async def create_paste (
152195 request : Request ,
153196 create_paste_body : CreatePaste ,
154197 paste_service : PasteService = Depends (Provide [Container .paste_service ]),
155198):
199+ """Create a new paste and return edit/delete tokens."""
156200 return await paste_service .create_paste (create_paste_body , request .state .user_metadata )
157201
158202
159- @pastes_route .put ("/{paste_id}" )
203+ @pastes_route .put (
204+ "/{paste_id}" ,
205+ response_model = PasteResponse ,
206+ summary = "Edit an existing paste" ,
207+ description = "Update a paste's content or metadata. Requires a valid edit token." ,
208+ )
160209@limiter .limit (create_limit_resolver (config , "edit_paste" ), key_func = get_exempt_key )
161210@inject
162211async def edit_paste (
@@ -166,22 +215,21 @@ async def edit_paste(
166215 edit_token : str = Security (edit_token_key_header ),
167216 paste_service : PasteService = Depends (Provide [Container .paste_service ]),
168217):
218+ """Edit an existing paste. Requires the edit token returned during creation."""
169219 result = await paste_service .edit_paste (paste_id , edit_paste_body , edit_token )
170220 if not result :
171- raise HTTPException (
172- status_code = 404 ,
173- detail = ErrorResponse (
174- error = "paste_not_found" ,
175- message = f"Paste { paste_id } not found" ,
176- ).model_dump (),
177- )
178- # Invalidate cache after successful edit
179- await cache .delete (paste_id )
180- cache_operations .labels (operation = "delete" , result = "success" ).inc ()
221+ raise PasteNotFoundError (str (paste_id ))
222+
223+ # Invalidate all cache entries for this paste
224+ await _invalidate_paste_cache (paste_id )
181225 return result
182226
183227
184- @pastes_route .delete ("/{paste_id}" )
228+ @pastes_route .delete (
229+ "/{paste_id}" ,
230+ summary = "Delete a paste" ,
231+ description = "Permanently delete a paste. Requires a valid delete token." ,
232+ )
185233@limiter .limit (create_limit_resolver (config , "delete_paste" ), key_func = get_exempt_key )
186234@inject
187235async def delete_paste (
@@ -190,16 +238,22 @@ async def delete_paste(
190238 delete_token : str = Security (delete_token_key_header ),
191239 paste_service : PasteService = Depends (Provide [Container .paste_service ]),
192240):
241+ """Delete a paste. Requires the delete token returned during creation."""
193242 result = await paste_service .delete_paste (paste_id , delete_token )
194243 if not result :
195- raise HTTPException (
196- status_code = 404 ,
197- detail = ErrorResponse (
198- error = "paste_not_found" ,
199- message = f"Paste { paste_id } not found" ,
200- ).model_dump (),
201- )
202- # Invalidate cache after successful delete
203- await cache .delete (paste_id )
204- cache_operations .labels (operation = "delete" , result = "success" ).inc ()
244+ raise PasteNotFoundError (str (paste_id ))
245+
246+ # Invalidate all cache entries for this paste
247+ await _invalidate_paste_cache (paste_id )
205248 return {"message" : "Paste deleted successfully" }
249+
250+
251+ async def _invalidate_paste_cache (paste_id : UUID4 ) -> None :
252+ """Invalidate all cache entries related to a paste."""
253+ cache_keys = [str (paste_id ), f"raw:{ paste_id } " ]
254+ for key in cache_keys :
255+ try :
256+ await cache .delete (key )
257+ cache_operations .labels (operation = "delete" , result = "success" ).inc ()
258+ except Exception as exc :
259+ logger .warning ("Failed to invalidate cache key %s: %s" , key , exc )
0 commit comments