├── .gitignore ├── BACKEND_INSTRUCTIONS.md ├── FRONTEND_INSTRUCTIONS.md ├── MOBILE_INSTRUCTIONS.md ├── RealWorld.postman_collection.json ├── articles ├── __init__.py ├── models.py └── views.py ├── authentication ├── __init__.py ├── models.py └── views.py ├── conduit ├── __init__.py ├── __main__.py ├── main.py └── settings.py ├── conftest.py ├── core ├── __init__.py ├── models.py └── utils.py ├── docker-compose.yml ├── init_db.py ├── logo.png ├── profiles ├── __init__.py ├── models.py └── views.py ├── readme.md ├── requirements.txt └── tests ├── __init__.py └── test_models.py /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /bower_components 6 | 7 | # IDEs and editors 8 | /venv 9 | /.idea 10 | .project 11 | .classpath 12 | *.launch 13 | .settings/ 14 | 15 | 16 | #System Files 17 | .DS_Store 18 | Thumbs.db 19 | /db.sqlite3 20 | -------------------------------------------------------------------------------- /BACKEND_INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | > *Note: Delete this file before publishing your app!* 2 | 3 | # [Backend API spec](https://github.com/gothinkster/realworld/tree/master/api) 4 | 5 | For your convenience, we have a [Postman collection](https://github.com/gothinkster/realworld/blob/master/api/Conduit.postman_collection.json) that you can use to test your API endpoints as you build your app. 6 | -------------------------------------------------------------------------------- /FRONTEND_INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | > *Note: Delete this file before publishing your app!* 2 | 3 | ### Using the hosted API 4 | 5 | Simply point your [API requests](https://github.com/gothinkster/realworld/tree/master/api) to `https://conduit.productionready.io/api` and you're good to go! 6 | 7 | ### Routing Guidelines 8 | 9 | - Home page (URL: /#/ ) 10 | - List of tags 11 | - List of articles pulled from either Feed, Global, or by Tag 12 | - Pagination for list of articles 13 | - Sign in/Sign up pages (URL: /#/login, /#/register ) 14 | - Uses JWT (store the token in localStorage) 15 | - Authentication can be easily switched to session/cookie based 16 | - Settings page (URL: /#/settings ) 17 | - Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here ) 18 | - Article page (URL: /#/article/article-slug-here ) 19 | - Delete article button (only shown to article's author) 20 | - Render markdown from server client side 21 | - Comments section at bottom of page 22 | - Delete comment button (only shown to comment's author) 23 | - Profile page (URL: /#/profile/:username, /#/profile/:username/favorites ) 24 | - Show basic user info 25 | - List of articles populated from author's created articles or author's favorited articles 26 | 27 | # Styles 28 | 29 | Instead of having the Bootstrap theme included locally, we recommend loading the precompiled theme from our CDN (our [header template](#header) does this by default): 30 | 31 | ```html 32 | 33 | ``` 34 | 35 | Alternatively, if you want to make modifications to the theme, check out the [theme's repo](https://github.com/gothinkster/conduit-bootstrap-template). 36 | 37 | 38 | # Templates 39 | 40 | - [Layout](#layout) 41 | - [Header](#header) 42 | - [Footer](#footer) 43 | - [Pages](#pages) 44 | - [Home](#home) 45 | - [Login/Register](#loginregister) 46 | - [Profile](#profile) 47 | - [Settings](#settings) 48 | - [Create/Edit Article](#createedit-article) 49 | - [Article](#article) 50 | 51 | 52 | ## Layout 53 | 54 | 55 | ### Header 56 | 57 | ```html 58 | 59 | 60 | 61 | 62 | Conduit 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 95 | 96 | 97 | ``` 98 | 99 | ### Footer 100 | ```html 101 | 109 | 110 | 111 | 112 | ``` 113 | 114 | ## Pages 115 | 116 | ### Home 117 | ```html 118 |
119 | 120 | 126 | 127 |
128 |
129 | 130 |
131 |
132 | 140 |
141 | 142 |
143 | 153 | 154 |

How to build webapps that scale

155 |

This is the description for the post.

156 | Read more... 157 |
158 |
159 | 160 |
161 | 171 | 172 |

The song you won't ever stop singing. No matter how hard you try.

173 |

This is the description for the post.

174 | Read more... 175 |
176 |
177 | 178 |
179 | 180 |
181 | 195 |
196 | 197 |
198 |
199 | 200 |
201 | ``` 202 | 203 | ### Login/Register 204 | 205 | ```html 206 |
207 |
208 |
209 | 210 |
211 |

Sign up

212 |

213 | Have an account? 214 |

215 | 216 |
    217 |
  • That email is already taken
  • 218 |
219 | 220 |
221 |
222 | 223 |
224 |
225 | 226 |
227 |
228 | 229 |
230 | 233 |
234 |
235 | 236 |
237 |
238 |
239 | ``` 240 | 241 | ### Profile 242 | 243 | ```html 244 |
245 | 246 |
247 |
248 |
249 | 250 |
251 | 252 |

Eric Simons

253 |

254 | Cofounder @GoThinkster, lived in Aol's HQ for a few months, kinda looks like Peeta from the Hunger Games 255 |

256 | 261 |
262 | 263 |
264 |
265 |
266 | 267 |
268 |
269 | 270 |
271 |
272 | 280 |
281 | 282 |
283 | 293 | 294 |

How to build webapps that scale

295 |

This is the description for the post.

296 | Read more... 297 |
298 |
299 | 300 |
301 | 311 | 312 |

The song you won't ever stop singing. No matter how hard you try.

313 |

This is the description for the post.

314 | Read more... 315 |
    316 |
  • Music
  • 317 |
  • Song
  • 318 |
319 |
320 |
321 | 322 | 323 |
324 | 325 |
326 |
327 | 328 |
329 | ``` 330 | 331 | ### Settings 332 | 333 | ```html 334 |
335 |
336 |
337 | 338 |
339 |

Your Settings

340 | 341 |
342 |
343 |
344 | 345 |
346 |
347 | 348 |
349 |
350 | 351 |
352 |
353 | 354 |
355 |
356 | 357 |
358 | 361 |
362 |
363 |
364 | 365 |
366 |
367 |
368 | ``` 369 | 370 | ### Create/Edit Article 371 | 372 | ```html 373 |
374 |
375 |
376 | 377 |
378 |
379 |
380 |
381 | 382 |
383 |
384 | 385 |
386 |
387 | 388 |
389 |
390 |
391 |
392 | 395 |
396 |
397 |
398 | 399 |
400 |
401 |
402 | 403 | 404 | ``` 405 | 406 | ### Article 407 | 408 | ```html 409 |
410 | 411 | 437 | 438 |
439 | 440 |
441 |
442 |

443 | Web development technologies have evolved at an incredible clip over the past few years. 444 |

445 |

Introducing RealWorld.

446 |

It's a great solution for learning how other frameworks work.

447 |
448 |
449 | 450 |
451 | 452 |
453 | 472 |
473 | 474 |
475 | 476 |
477 | 478 |
479 |
480 | 481 |
482 | 488 |
489 | 490 |
491 |
492 |

With supporting text below as a natural lead-in to additional content.

493 |
494 | 502 |
503 | 504 |
505 |
506 |

With supporting text below as a natural lead-in to additional content.

507 |
508 | 520 |
521 | 522 |
523 | 524 |
525 | 526 |
527 | 528 |
529 | ``` 530 | -------------------------------------------------------------------------------- /MOBILE_INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | > *Note: Delete this file before publishing your app!* 2 | 3 | # [Mobile Icons (iOS/Android)](https://github.com/gothinkster/realworld/tree/master/spec/mobile_icons) 4 | 5 | ### Using the hosted API 6 | 7 | Simply point your [API requests](https://github.com/gothinkster/realworld/tree/master/api) to `https://conduit.productionready.io/api` and you're good to go! 8 | 9 | ### Styles/Templates 10 | 11 | Unfortunately, there isn't a common way for us to reuse & share styles/templates for cross-platform mobile apps. 12 | 13 | Instead, we recommend using the Medium.com [iOS](https://itunes.apple.com/us/app/medium/id828256236?mt=8) and [Android](https://play.google.com/store/apps/details?id=com.medium.reader&hl=en) apps as a "north star" regarding general UI functionality/layout, but try not to go too overboard otherwise it will unnecessarily complicate your codebase (in other words, [KISS](https://en.wikipedia.org/wiki/KISS_principle) :) 14 | -------------------------------------------------------------------------------- /RealWorld.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "d866bdf7-8a8f-4935-90f0-03c2256a7a81", 4 | "name": "RealWorld", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "0.0.0.0:8080/api/users", 10 | "request": { 11 | "method": "POST", 12 | "header": [ 13 | { 14 | "key": "Authorization", 15 | "value": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InRlc3QifQ.pyNsXX_vNsUvdt6xu13F1Gs1zGELT4Va8a38eG5svBA", 16 | "type": "text", 17 | "disabled": true 18 | }, 19 | { 20 | "key": "Content-Type", 21 | "name": "Content-Type", 22 | "value": "application/json", 23 | "type": "text" 24 | } 25 | ], 26 | "body": { 27 | "mode": "raw", 28 | "raw": "{\n \"user\":{\n \"username\": \"Jacob\",\n \"email\": \"jake@jake.jake\",\n \"password\": \"jakejake\"\n }\n}", 29 | "options": { 30 | "raw": { 31 | "language": "json" 32 | } 33 | } 34 | }, 35 | "url": { 36 | "raw": "0.0.0.0:8080/api/users", 37 | "host": [ 38 | "0", 39 | "0", 40 | "0", 41 | "0" 42 | ], 43 | "port": "8080", 44 | "path": [ 45 | "api", 46 | "users" 47 | ] 48 | }, 49 | "description": "Create an user." 50 | }, 51 | "response": [] 52 | }, 53 | { 54 | "name": "0.0.0.0:8080/api/users/login", 55 | "request": { 56 | "method": "POST", 57 | "header": [ 58 | { 59 | "key": "Authorization", 60 | "value": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InRlc3QifQ.pyNsXX_vNsUvdt6xu13F1Gs1zGELT4Va8a38eG5svBA", 61 | "type": "text", 62 | "disabled": true 63 | }, 64 | { 65 | "key": "Content-Type", 66 | "name": "Content-Type", 67 | "value": "application/json", 68 | "type": "text" 69 | } 70 | ], 71 | "body": { 72 | "mode": "raw", 73 | "raw": "{\n \"user\":{\n \"email\": \"jake@jake.jake\",\n \"password\": \"jakejake\"\n }\n}", 74 | "options": { 75 | "raw": { 76 | "language": "json" 77 | } 78 | } 79 | }, 80 | "url": { 81 | "raw": "0.0.0.0:8080/api/users/login", 82 | "host": [ 83 | "0", 84 | "0", 85 | "0", 86 | "0" 87 | ], 88 | "port": "8080", 89 | "path": [ 90 | "api", 91 | "users", 92 | "login" 93 | ] 94 | }, 95 | "description": "User login" 96 | }, 97 | "response": [] 98 | }, 99 | { 100 | "name": "0.0.0.0:8080/api/user", 101 | "request": { 102 | "method": "GET", 103 | "header": [ 104 | { 105 | "key": "Authorization", 106 | "value": "Token eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNTg2MjY3NzQ2fQ.8Qks1cyQhLP6984fHaa-k7sjz3j9re5FQE0osuiuG0E", 107 | "type": "text" 108 | } 109 | ], 110 | "url": { 111 | "raw": "0.0.0.0:8080/api/user", 112 | "host": [ 113 | "0", 114 | "0", 115 | "0", 116 | "0" 117 | ], 118 | "port": "8080", 119 | "path": [ 120 | "api", 121 | "user" 122 | ] 123 | } 124 | }, 125 | "response": [] 126 | }, 127 | { 128 | "name": "0.0.0.0:8080/api/user", 129 | "request": { 130 | "method": "PUT", 131 | "header": [ 132 | { 133 | "key": "Authorization", 134 | "value": "Token eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNTg2MjY3NzQ2fQ.8Qks1cyQhLP6984fHaa-k7sjz3j9re5FQE0osuiuG0E", 135 | "type": "text" 136 | }, 137 | { 138 | "key": "Content-Type", 139 | "name": "Content-Type", 140 | "value": "application/json", 141 | "type": "text" 142 | } 143 | ], 144 | "body": { 145 | "mode": "raw", 146 | "raw": "{\n \"user\":{\n \t\"username\": \"Jack\",\n \"email\": \"jake_2@jake.jake\",\n \"bio\": \"I like to skateboard\",\n \"image\": \"https://i.stack.imgur.com/xHWG8_4.jpg\" \n }\n}", 147 | "options": { 148 | "raw": { 149 | "language": "json" 150 | } 151 | } 152 | }, 153 | "url": { 154 | "raw": "0.0.0.0:8080/api/user", 155 | "host": [ 156 | "0", 157 | "0", 158 | "0", 159 | "0" 160 | ], 161 | "port": "8080", 162 | "path": [ 163 | "api", 164 | "user" 165 | ] 166 | } 167 | }, 168 | "response": [] 169 | } 170 | ], 171 | "protocolProfileBehavior": {} 172 | } -------------------------------------------------------------------------------- /articles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nomhoi/aiohttp-realworld-example-app/9e6d0dd0d59c63ccdf6ce6f449b847b4a848b345/articles/__init__.py -------------------------------------------------------------------------------- /articles/models.py: -------------------------------------------------------------------------------- 1 | from core.models import TimestampedMixin, AbstractBaseModel 2 | 3 | 4 | class Article(TimestampedMixin, AbstractBaseModel): 5 | pass 6 | -------------------------------------------------------------------------------- /articles/views.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | routes = web.RouteTableDef() 4 | -------------------------------------------------------------------------------- /authentication/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nomhoi/aiohttp-realworld-example-app/9e6d0dd0d59c63ccdf6ce6f449b847b4a848b345/authentication/__init__.py -------------------------------------------------------------------------------- /authentication/models.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | from datetime import datetime, timedelta 3 | from tortoise import fields 4 | from conduit import settings 5 | from core.models import TimestampedMixin, AbstractBaseModel 6 | 7 | 8 | class User(TimestampedMixin, AbstractBaseModel): 9 | username = fields.CharField(db_index=True, max_length=255, unique=True) 10 | email = fields.CharField(db_index=True, max_length=255, unique=True) 11 | password = fields.CharField(max_length=128) 12 | 13 | class Meta: 14 | table = "user" 15 | 16 | def __str__(self): 17 | return self.username 18 | 19 | @property 20 | def token(self): 21 | """ 22 | Allows us to get a user's token by calling `user.token` instead of 23 | `user.generate_jwt_token(). 24 | 25 | The `@property` decorator above makes this possible. `token` is called 26 | a "dynamic property". 27 | """ 28 | return self._generate_jwt_token() 29 | 30 | def _generate_jwt_token(self): 31 | """ 32 | Generates a JSON Web Token that stores this user's ID and has an expiry 33 | date set to 60 days into the future. 34 | """ 35 | dt = datetime.now() + timedelta(days=60) 36 | 37 | token = jwt.encode({ 38 | 'id': self.pk, 39 | 'exp': int(dt.strftime('%s')) 40 | }, settings.SECRET_KEY, algorithm='HS256') 41 | 42 | return token.decode('utf-8') 43 | -------------------------------------------------------------------------------- /authentication/views.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from tortoise import exceptions 3 | from core.utils import error_response 4 | from authentication.models import User 5 | from profiles.models import Profile 6 | 7 | routes = web.RouteTableDef() 8 | 9 | 10 | @routes.post('/api/users') 11 | async def create_user(request): 12 | try: 13 | loaded_json = await request.json() 14 | 15 | username = loaded_json['user']['username'] 16 | if username == "": 17 | return error_response("username", "username is empty") 18 | 19 | email = loaded_json['user']['email'] 20 | if email == "": 21 | return error_response("email", "email is empty") 22 | 23 | password = loaded_json['user']['password'] 24 | if password == "": 25 | return error_response("password", "password is empty") 26 | 27 | user = await User.create(username=username, email=email, password=password) # TODO: set_password 28 | bio = "" 29 | image = "" 30 | if user is not None: 31 | profile = await Profile.get_or_none(user=user) 32 | if profile is not None: 33 | bio = profile.bio 34 | image = profile.image 35 | 36 | return web.json_response( 37 | { 38 | "user": { 39 | "email": user.email, 40 | "username": user.username, 41 | "token": user.token, 42 | "bio": bio, 43 | "image": image 44 | } 45 | }, 46 | status=201 47 | ) 48 | except exceptions.IntegrityError as err: 49 | return error_response("database error", str(err)) 50 | except Exception as err: 51 | return error_response("error", str(err)) 52 | 53 | 54 | @routes.post('/api/users/login') 55 | async def login_user(request): 56 | try: 57 | loaded_json = await request.json() 58 | 59 | email = loaded_json['user']['email'] 60 | password = loaded_json['user']['password'] 61 | 62 | user = await User.filter(email=email).first() 63 | if user is not None: 64 | if user.password == password: # TODO: check password 65 | bio = "" 66 | image = "" 67 | profile = await Profile.get_or_none(user=user) 68 | if profile is not None: 69 | bio = profile.bio 70 | image = profile.image 71 | 72 | return web.json_response( 73 | { 74 | "user": { 75 | "email": user.email, 76 | "username": user.username, 77 | "token": user.token, 78 | "bio": bio, 79 | "image": image 80 | } 81 | }, 82 | status=200 83 | ) 84 | else: 85 | return error_response("password", "password error") 86 | else: 87 | return error_response("user", "user not found") 88 | except exceptions.IntegrityError as err: 89 | return error_response("database error", str(err)) 90 | except Exception as err: 91 | return error_response("error", str(err)) 92 | 93 | 94 | @routes.get('/api/user') 95 | async def get_user(request): 96 | try: 97 | user_id = int(request['payload']['id']) 98 | user = await User.filter(id=user_id).first() 99 | if user is not None: 100 | bio = "" 101 | image = "" 102 | profile = await Profile.get_or_none(user=user) 103 | if profile is not None: 104 | bio = profile.bio 105 | image = profile.image 106 | 107 | return web.json_response( 108 | { 109 | "user": { 110 | "email": user.email, 111 | "username": user.username, 112 | "token": user.token, 113 | "bio": bio, 114 | "image": image 115 | } 116 | }, 117 | status=200 118 | ) 119 | else: 120 | return error_response("user", "user not found") 121 | except exceptions.IntegrityError as err: 122 | return error_response("database error", str(err)) 123 | except Exception as err: 124 | return error_response("error", str(err)) 125 | 126 | 127 | @routes.put('/api/user') 128 | async def put_user(request): 129 | try: 130 | print('payload:', request['payload']) 131 | loaded_json = await request.json() 132 | data = loaded_json['user'] 133 | print('data:', data) 134 | 135 | user_id = int(request['payload']['id']) 136 | user = await User.get_or_none(id=user_id) 137 | if user is not None: 138 | if data.get('username') is not None: 139 | user.username = data['username'] 140 | if user.username == "": 141 | return error_response("username", "username is empty") 142 | if data.get('email') is not None: 143 | user.email = data['email'] 144 | if user.email == "": 145 | return error_response("email", "email is empty") 146 | if data.get('password') is not None: 147 | user.password = data['password'] 148 | if user.password == "": 149 | return error_response("password", "password is empty") 150 | 151 | await user.save() 152 | 153 | profile = await Profile.get_or_none(user=user) 154 | if profile is None: 155 | profile = await Profile(user=user) 156 | 157 | if data.get('bio') is not None: 158 | profile.bio = data['bio'] 159 | if data.get('image') is not None: 160 | profile.image = data['image'] 161 | 162 | await profile.save() 163 | 164 | return web.json_response( 165 | { 166 | "user": { 167 | "email": user.email, 168 | "username": user.username, 169 | "token": user.token, 170 | "bio": profile.bio, 171 | "image": profile.image 172 | } 173 | }, 174 | status=200 175 | ) 176 | else: 177 | return error_response("user", "user not found") 178 | except exceptions.IntegrityError as err: 179 | return error_response("database error", str(err)) 180 | except Exception as err: 181 | return error_response("error", str(err)) 182 | -------------------------------------------------------------------------------- /conduit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nomhoi/aiohttp-realworld-example-app/9e6d0dd0d59c63ccdf6ce6f449b847b4a848b345/conduit/__init__.py -------------------------------------------------------------------------------- /conduit/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from conduit.main import main 3 | 4 | main(sys.argv[1:]) 5 | -------------------------------------------------------------------------------- /conduit/main.py: -------------------------------------------------------------------------------- 1 | import aiohttp_cors 2 | import asyncio 3 | import logging 4 | import sys 5 | import uvloop 6 | from aiohttp import web 7 | from aiohttp_jwt import JWTMiddleware 8 | from tortoise import Tortoise 9 | from conduit import settings 10 | from authentication.views import routes as authentication_routes 11 | from profiles.views import routes as profiles_routes 12 | from articles.views import routes as articles_routes 13 | 14 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 15 | 16 | 17 | async def on_shutdown(app): 18 | await Tortoise.close_connections() 19 | 20 | 21 | def init(argv=None): 22 | app = web.Application( 23 | middlewares=[ 24 | JWTMiddleware( 25 | settings.SECRET_KEY, 26 | auth_scheme='Token', 27 | whitelist=[ 28 | r'/api/users', 29 | ] 30 | ) 31 | ] 32 | ) 33 | 34 | app.router.add_routes(authentication_routes) 35 | app.router.add_routes(profiles_routes) 36 | app.router.add_routes(articles_routes) 37 | 38 | # Configure default CORS settings. 39 | cors = aiohttp_cors.setup(app, defaults={ 40 | "*": aiohttp_cors.ResourceOptions( 41 | allow_credentials=True, 42 | expose_headers="*", 43 | allow_headers="*", 44 | ) 45 | }) 46 | 47 | # Configure CORS on all routes. 48 | for route in list(app.router.routes()): 49 | cors.add(route) 50 | 51 | app.on_shutdown.append(on_shutdown) 52 | 53 | return app 54 | 55 | 56 | def main(argv): 57 | logging.basicConfig(level=logging.DEBUG) 58 | 59 | loop = asyncio.get_event_loop() 60 | loop.run_until_complete(Tortoise.init(db_url=settings.DB_URL, 61 | modules={'models': settings.MODELS})) 62 | 63 | web.run_app(init(argv)) 64 | 65 | 66 | if __name__ == '__main__': 67 | main(sys.argv[1:]) 68 | -------------------------------------------------------------------------------- /conduit/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'secret' 2 | 3 | INSTALLED_APPS = [ 4 | 'authentication', 5 | 'profiles', 6 | 'articles' 7 | ] 8 | 9 | MODELS = ["{}.models".format(app) for app in INSTALLED_APPS] 10 | 11 | DB_URL = "sqlite://db.sqlite3" 12 | DB_URL_TEST = "sqlite://:memory:" 13 | # DB_URL = "postgres://postgres:postgres@0.0.0.0:5432/postgres" 14 | # DB_URL_TEST = "postgres://postgres:postgres@0.0.0.0:5432/test_{}" 15 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tortoise.contrib.test import finalizer, initializer 3 | from conduit import settings 4 | 5 | 6 | @pytest.fixture(scope="session", autouse=True) 7 | def initialize_tests(request): 8 | initializer(settings.MODELS, db_url=settings.DB_URL_TEST) 9 | request.addfinalizer(finalizer) 10 | -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nomhoi/aiohttp-realworld-example-app/9e6d0dd0d59c63ccdf6ce6f449b847b4a848b345/core/__init__.py -------------------------------------------------------------------------------- /core/models.py: -------------------------------------------------------------------------------- 1 | from tortoise import fields 2 | from tortoise.models import Model 3 | 4 | 5 | class TimestampedMixin: 6 | created_at = fields.DatetimeField(auto_now_add=True) 7 | updated_at = fields.DatetimeField(auto_now=True) 8 | 9 | class Meta: 10 | ordering = ['-created_at', '-updated_at'] 11 | 12 | 13 | class AbstractBaseModel(Model): 14 | id = fields.IntField(pk=True) 15 | 16 | class Meta: 17 | abstract = True 18 | -------------------------------------------------------------------------------- /core/utils.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | 4 | def error_response(name, description): 5 | return web.json_response( 6 | { 7 | "errors": { 8 | name: [ 9 | description 10 | ] 11 | } 12 | }, 13 | status=400 14 | ) 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | db: 5 | image: postgres:12.1-alpine 6 | container_name: db 7 | volumes: 8 | - postgres_data:/var/lib/postgresql/data/ 9 | ports: 10 | - "5432:5432" 11 | 12 | volumes: 13 | postgres_data: 14 | -------------------------------------------------------------------------------- /init_db.py: -------------------------------------------------------------------------------- 1 | from tortoise import Tortoise, run_async 2 | from conduit import settings 3 | 4 | 5 | async def init(): 6 | await Tortoise.init( 7 | db_url=settings.DB_URL, 8 | modules={'models': settings.MODELS} 9 | ) 10 | await Tortoise.generate_schemas() 11 | 12 | if __name__ == "__main__": 13 | run_async(init()) 14 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nomhoi/aiohttp-realworld-example-app/9e6d0dd0d59c63ccdf6ce6f449b847b4a848b345/logo.png -------------------------------------------------------------------------------- /profiles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nomhoi/aiohttp-realworld-example-app/9e6d0dd0d59c63ccdf6ce6f449b847b4a848b345/profiles/__init__.py -------------------------------------------------------------------------------- /profiles/models.py: -------------------------------------------------------------------------------- 1 | from tortoise import fields, models 2 | from core.models import TimestampedMixin 3 | 4 | 5 | class Profile(TimestampedMixin, models.Model): 6 | user = fields.OneToOneField('models.User', on_delete=fields.CASCADE, db_index=True) 7 | bio = fields.TextField(null=True) 8 | image = fields.CharField(max_length=200, null=True) 9 | -------------------------------------------------------------------------------- /profiles/views.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | routes = web.RouteTableDef() 4 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](logo.png) 2 | 3 | > ### aiohttp codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 4 | 5 | 6 | ### [Demo](https://github.com/gothinkster/realworld)    [RealWorld](https://github.com/gothinkster/realworld) 7 | 8 | 9 | This codebase was created to demonstrate a fully fledged fullstack application built with **aiohttp** including CRUD operations, authentication, routing, pagination, and more. 10 | 11 | We've gone to great lengths to adhere to the **aiohttp** community styleguides & best practices. 12 | 13 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 14 | 15 | 16 | # How it works 17 | 18 | > Describe the general architecture of your app here 19 | 20 | # Getting started 21 | 22 | Clone the repository: 23 | > git clone git@github.com:nomhoi/aiohttp-realworld-example-app.git 24 | 25 | Install requirements: 26 | > pip install -r requirements.txt 27 | 28 | Init database: 29 | > python init_db.py 30 | 31 | Run the server: 32 | > python -m conduit 33 | 34 | Run tests: 35 | > pytest 36 | 37 | Postman collection: [RealWorld.postman_collection.json](https://github.com/nomhoi/aiohttp-realworld-example-app/blob/master/RealWorld.postman_collection.json) 38 | 39 | ## PostgreSQL 40 | 41 | Set these variables in conduit.settings: 42 | ```python 43 | DB_URL = "postgres://postgres:postgres@0.0.0.0:5432/postgres" 44 | DB_URL_TEST = "postgres://postgres:postgres@0.0.0.0:5432/test_{}" 45 | ``` 46 | 47 | Run the container: 48 | > docker-compose up -d 49 | 50 | 51 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.6.2 2 | aiohttp_cors==0.7.0 3 | aiohttp_jwt==0.5.0 4 | aiosqlite==0.11.0 5 | asyncpg==0.20.1 6 | asynctest==0.13.0 7 | pytest-asyncio==0.10.0 8 | tortoise-orm==0.15.13 9 | uvloop==0.14.0 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nomhoi/aiohttp-realworld-example-app/9e6d0dd0d59c63ccdf6ce6f449b847b4a848b345/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from tortoise.contrib import test 2 | from authentication.models import User 3 | from profiles.models import Profile 4 | 5 | 6 | class TestModels(test.TestCase): 7 | async def setUp(self): 8 | self.user = await User.create(username='username', email='email', password='password') 9 | 10 | async def test_user_and_profile(self): 11 | assert await User.all().count() == 1 12 | 13 | profile = await Profile(user=self.user) 14 | await profile.save() 15 | 16 | assert profile.user == self.user 17 | assert await Profile.all().count() == 1 18 | --------------------------------------------------------------------------------