Skip to content

Commit

Permalink
Merge pull request #564 from dzcode-io/555-move-articles-endpoint-fro…
Browse files Browse the repository at this point in the history
…m-data-to-api
  • Loading branch information
ZibanPirate authored Apr 3, 2023
2 parents 9a81c50 + acd68dd commit 0c47467
Show file tree
Hide file tree
Showing 17 changed files with 200 additions and 120 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ If you use VSCode, please make sure to have a `.vscode/settings.json` file with
```json
{
"prettier.configPath": "packages/tooling/.prettierrc",
"eslint.options": { "overrideConfigFile": "packages/tooling/.eslintrc.json" }
"eslint.options": { "overrideConfigFile": "packages/tooling/.eslintrc.json" },
"editor.codeActionsOnSave": {
"source.fixAll": true
}
}
```

Expand Down
16 changes: 6 additions & 10 deletions api/src/app/endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
import { LanguageEntity } from "@dzcode.io/models/dist/language";
import { GetArticleResponseDto, GetArticlesResponseDto } from "src/article/types";
import { GetContributionsResponseDto } from "src/contribution/types";
import { GetContributorsResponseDto } from "src/contributor/types";
import { GetUserResponseDto } from "src/github-user/types";
import { GetMilestonesResponseDto } from "src/milestone/types";
import { GetProjectsResponseDto } from "src/project/types";
import { GetTeamResponseDto } from "src/team/types";

import { Article, Document } from "./types/legacy";
import { Document } from "./types/legacy";

// @TODO-ZM: remove old endpoints
export interface Endpoints {
"data:articles/list.c.json": {
response: Pick<Article, "title" | "slug">[];
query: [["language", LanguageEntity["code"]]];
"api:Articles": {
response: GetArticlesResponseDto;
};
"data:articles/:slug.json": {
response: Article;
"api:Articles/:slug": {
response: GetArticleResponseDto;
params: { slug: string };
query: [["language", LanguageEntity["code"]]];
};
"data:articles/top-articles.c.json": {
response: Article[]; // TODO-ZM: should be: Pick<Article, "title" | "slug">[] instead
};
"data:documentation/list.c.json": {
response: Pick<Document, "title" | "slug">[];
Expand Down
2 changes: 2 additions & 0 deletions api/src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fsConfig } from "@dzcode.io/utils/dist/config";
import * as Sentry from "@sentry/node";
import { Application } from "express";
import { createExpressServer, RoutingControllersOptions, useContainer } from "routing-controllers";
import { ArticleController } from "src/article/controller";
import { ConfigService } from "src/config/service";
import { ContributionController } from "src/contribution/controller";
import { ContributorController } from "src/contributor/controller";
Expand Down Expand Up @@ -48,6 +49,7 @@ export const routingControllersOptions: RoutingControllersOptions = {
GithubController,
MilestoneController,
ProjectController,
ArticleController,
],
middlewares: [
SentryRequestHandlerMiddleware,
Expand Down
101 changes: 101 additions & 0 deletions api/src/article/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Controller, Get, Param } from "routing-controllers";
import { OpenAPI, ResponseSchema } from "routing-controllers-openapi";
import { GithubUser } from "src/app/types/legacy";
import { DataService } from "src/data/service";
import { GithubService } from "src/github/service";
import { Service } from "typedi";

import { GetArticleResponseDto, GetArticlesResponseDto } from "./types";

@Service()
@Controller("/Articles")
export class ArticleController {
constructor(
private readonly githubService: GithubService,
private readonly dataService: DataService,
) {}

@Get("/")
@OpenAPI({
summary: "Return list of all articles",
})
@ResponseSchema(GetArticlesResponseDto)
public async getArticles(): Promise<GetArticlesResponseDto> {
// get articles from /data folder:
const articles = await this.dataService.listArticles();

return {
articles,
};
}

@Get("/:slug")
@OpenAPI({
summary: "Return info about a single article",
})
@ResponseSchema(GetArticleResponseDto)
public async getArticle(@Param("slug") slug: string): Promise<GetArticleResponseDto> {
// get articles from /data folder:
const { ...article } = await this.dataService.getArticle(slug);

// get authors and contributors info from github:
const authors = await Promise.all(
article.authors.map(async (author) => {
const githubUser = await this.githubService.getUser({ username: author });
return {
id: `github/${githubUser.id}`,
name: githubUser.login,
link: githubUser.html_url,
image: githubUser.avatar_url,
};
}),
);

const contributorsBatches = await Promise.all([
// current place for data:
this.githubService.listContributors({
owner: "dzcode-io",
repository: "dzcode.io",
path: `data/models/articles/${slug}`,
}),
// also check old place for data, to not lose contribution effort:
this.githubService.listContributors({
owner: "dzcode-io",
repository: "dzcode.io",
path: `data/articles/${slug}`,
}),
]);

// filter and sort contributors:
const uniqUsernames: Record<string, number> = {};
const contributors: GetArticleResponseDto["article"]["contributors"] = [
...contributorsBatches[0],
...contributorsBatches[1],
]
.reduce<GithubUser[]>((pV, cV) => {
if (uniqUsernames[cV.login]) {
uniqUsernames[cV.login]++;
return pV;
} else {
uniqUsernames[cV.login] = 1;
return [...pV, cV];
}
}, [])
.sort((a, b) => uniqUsernames[b.login] - uniqUsernames[a.login])
.map((contributor) => ({
id: `github/${contributor.id}`,
name: contributor.login,
link: contributor.html_url,
image: contributor.avatar_url,
}))
.filter(({ id }) => !authors.find((author) => author.id === id));

return {
article: {
...article,
authors,
contributors,
},
};
}
}
16 changes: 16 additions & 0 deletions api/src/article/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Model } from "@dzcode.io/models/dist/_base";
import { ArticleEntity, ArticleInfoEntity } from "@dzcode.io/models/dist/article";
import { Type } from "class-transformer";
import { ValidateNested } from "class-validator";
import { GeneralResponseDto } from "src/app/types";

export class GetArticlesResponseDto extends GeneralResponseDto {
@ValidateNested({ each: true })
@Type(() => ArticleInfoEntity)
articles!: Model<ArticleInfoEntity>[];
}

export class GetArticleResponseDto extends GeneralResponseDto {
@ValidateNested()
article!: Model<ArticleEntity, "authors" | "contributors">;
}
28 changes: 22 additions & 6 deletions api/src/data/service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { getCollection } from "@dzcode.io/data/dist/get/collection";
import { getEntry } from "@dzcode.io/data/dist/get/entry";
import { join } from "path";
import { ConfigService } from "src/config/service";
import { Service } from "typedi";

import { ProjectReferenceEntity } from "./types";
import { DataArticleEntity, DataProjectEntity } from "./types";

@Service()
export class DataService {
constructor(private readonly configService: ConfigService) {}

public listProjects = async (): Promise<ProjectReferenceEntity[]> => {
const projects = getCollection<ProjectReferenceEntity>(
public listProjects = async (): Promise<DataProjectEntity[]> => {
const projects = getCollection<DataProjectEntity>(
this.dataModelsPath,
"projects-v2",
"list.json",
Expand All @@ -21,5 +19,23 @@ export class DataService {
return projects;
};

public listArticles = async (): Promise<Pick<DataArticleEntity, "slug" | "title">[]> => {
const articles = getCollection<DataArticleEntity>(this.dataModelsPath, "articles", "list.json");

if (articles === 404) throw new Error("Articles list not found");

const mappedArticles = articles.map(({ slug, title }) => ({ slug, title }));

return mappedArticles;
};

public getArticle = async (slug: string): Promise<DataArticleEntity> => {
const article = getEntry<DataArticleEntity>(this.dataModelsPath, `articles/${slug}`);

if (article === 404) throw new Error("Article not found");

return article;
};

private dataModelsPath = join(__dirname, "../../../data");
}
13 changes: 9 additions & 4 deletions api/src/data/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { Model } from "@dzcode.io/models/dist/_base";
import { ArticleInfoEntity } from "@dzcode.io/models/dist/article";
import { ProjectEntity } from "@dzcode.io/models/dist/project";
import { RepositoryEntity } from "@dzcode.io/models/dist/repository";

export interface ProjectReferenceEntity extends Model<ProjectEntity> {
repositories: Model<RepositoryEntity>[];
}
export type DataProjectEntity = Model<ProjectEntity, "repositories">;

export type DataArticleEntity = Model<ArticleInfoEntity> & {
authors: string[];
description: string;
content: string;
image: string;
};
5 changes: 2 additions & 3 deletions mobile/src/redux/actions/articles-screen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { fetchV2 } from "src/utils/fetch";

export const fetchArticles = createAsyncThunk("articlesScreen/fetchArticles", async () => {
try {
const articles = await fetchV2("data:articles/list.c.json", {
const { articles } = await fetchV2("api:Articles", {
query: [["language", "en"]],
});
return articles;
Expand All @@ -16,9 +16,8 @@ export const fetchArticle = createAsyncThunk(
"articlesScreen/fetchArticle",
async (slug: string) => {
try {
const article = await fetchV2(`data:articles/:slug.json`, {
const { article } = await fetchV2(`api:Articles/:slug`, {
params: { slug },
query: [["language", "en"]],
});
return article;
} catch (error: any) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Endpoints } from "@dzcode.io/api/dist/app/endpoints";
import { createEntityAdapter } from "@reduxjs/toolkit";

type Article =
| Endpoints["data:articles/list.c.json"]["response"][number]
| Endpoints["data:articles/:slug.json"]["response"];
| Endpoints["api:Articles"]["response"]["articles"][number]
| Endpoints["api:Articles/:slug"]["response"]["article"];

export const articlesAdapter = createEntityAdapter<Article>({
selectId: (article) => article.slug,
Expand Down
4 changes: 3 additions & 1 deletion mobile/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
"compilerOptions": {
"strict": true,
"baseUrl": "."
}
},
"include": ["src"],
"exclude": ["src/_e2e-test"]
}
24 changes: 12 additions & 12 deletions packages/models/src/article/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ ArticleEntity {
"content": "test-content",
"contributors": [],
"description": "test-description",
"id": "learn/Getting_Started",
"image": "https://images.unsplash.com/photo-1520338661084-680395057c93?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=formatfit=crop&w=800&q=100",
"slug": "learn/Getting_Started",
"title": "Getting Started",
}
`;
Expand All @@ -22,8 +22,8 @@ ArticleEntity {
"content": "test-content",
"contributors": [],
"description": "test-description",
"id": "learn/Getting_Started",
"image": "https://images.unsplash.com/photo-1520338661084-680395057c93?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=formatfit=crop&w=800&q=100",
"slug": "learn/Getting_Started",
"title": "Getting Started",
}
`;
Expand All @@ -33,45 +33,45 @@ exports[`should show an error that matches snapshot when passing empty object: e
ValidationError {
"children": [],
"constraints": {
"isString": "id must be a string",
"isString": "image must be a string",
},
"property": "id",
"property": "image",
"target": ArticleEntity {},
"value": undefined,
},
ValidationError {
"children": [],
"constraints": {
"isString": "image must be a string",
"isString": "description must be a string",
},
"property": "image",
"property": "description",
"target": ArticleEntity {},
"value": undefined,
},
ValidationError {
"children": [],
"constraints": {
"isString": "title must be a string",
"isString": "content must be a string",
},
"property": "title",
"property": "content",
"target": ArticleEntity {},
"value": undefined,
},
ValidationError {
"children": [],
"constraints": {
"isString": "description must be a string",
"isString": "slug must be a string",
},
"property": "description",
"property": "slug",
"target": ArticleEntity {},
"value": undefined,
},
ValidationError {
"children": [],
"constraints": {
"isString": "content must be a string",
"isString": "title must be a string",
},
"property": "content",
"property": "title",
"target": ArticleEntity {},
"value": undefined,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/models/src/article/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ runDTOTestCases(
{
image:
"https://images.unsplash.com/photo-1520338661084-680395057c93?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=formatfit=crop&w=800&q=100",
id: "learn/Getting_Started",
slug: "learn/Getting_Started",
title: "Getting Started",
description: "test-description",
content: "test-content",
Expand Down
10 changes: 6 additions & 4 deletions packages/models/src/article/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import { IsString, ValidateNested } from "class-validator";
import { BaseEntity, Model } from "src/_base";
import { AccountEntity } from "src/account";

export class ArticleEntity extends BaseEntity {
export class ArticleInfoEntity extends BaseEntity {
@IsString()
id!: string;
slug!: string;

@IsString()
image!: string;
title!: string;
}

export class ArticleEntity extends ArticleInfoEntity {
@IsString()
title!: string;
image!: string;

@IsString()
description!: string;
Expand Down
1 change: 1 addition & 0 deletions packages/models/src/contributor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { IsString, IsUrl, ValidateNested } from "class-validator";
import { BaseEntity, Model } from "src/_base";
import { RepositoryReferenceEntity } from "src/repository-reference";

// @TODO-ZM: remove this in favour of AccountEntity
export class ContributorEntity extends BaseEntity {
@IsString()
id!: string;
Expand Down
Loading

0 comments on commit 0c47467

Please sign in to comment.