NOTE: The following will work if you extend DS.RestAdapter (not DS.JSONAPIAdapter). jsonapi.org as has changed significantly since the writing of this document. At that time (in beta) it was compliant. The ember-data team still seem to support the DS.RestAdapter format which is part of ember-data core. |
There is little out there on integrating ember-data with a Java Backend. This post introduces a technique I have used to properly format JSON for use with the RestAdapter from a Java Spring Backend. The techniques are based off this git repository with a few modifications. Since ember-data is such a great tool it is a shame there is little documentation on integration with frameworks other than RAILS. I went through the pain of trial and error to get the integration to work properly. Hopefully this post means you don't have to!
A working application can be downloaded from https://github.com/jackmatt2/ember-spring. Import the project into Eclipse/STS and start the application on a Tomcat/Pivotal tc server.
For the purposes of this tutorial, we will be using the following hibernate entities.
Note: I have removed all the Hibernate/JPA annotations for brevity so we can focus solely on the Jackson annotations.
public class Blog { private Long id; private boolean active; private String name; private Category category; private Date createDate; private Listposts = new ArrayList (0); //getters and setters ... }
public class Post { private Long id; private String comment; private Blog blog; private Date createDate; //getters and setters ... }
public class Category { private Long id; private String name; private Listblogs = new ArrayList (0); //getters and setters ... }
The corresponding Ember models look like this:
App.Blog = DS.Model.extend({ active: DS.attr('boolean'), name: DS.attr('string'), createDate : DS.attr('date'), category: DS.belongsTo('category'), posts : DS.hasMany('post', {async : true}) });
App.Post = DS.Model.extend({ comment: DS.attr('string'), blog : DS.belongsTo('blog'), createDate : DS.attr('date') });
App.Category = DS.Model.extend({ name: DS.attr('string'), blogs : DS.hasMany('post', {async : true}) });Notice I am using {async : true} for the hasMany relationships. This tells ember-data to fetch the relationship from the server only when they are explicitly needed, thus allowing them to be lazy loaded. When {async : false} is specified (the default but there is a push to change this), ember-data expects the data to have been loaded with the initial JSON request.
Dependencies
Dependency | Version |
---|---|
Java | 1.6 |
Spring Framework | 4.0.6.RELEASE |
Jackson | 2.3.3 |
Ember | 1.6.1 |
Ember Data | canary |
<!-- English pluralization library --> <dependency> <groupId>org.atteo</groupId> <artifactId>evo-inflector</artifactId> <version>1.2</version> </dependency> <!-- Guava --> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>17.0</version> </dependency>
Part 1 - Requesting JSON from a Spring Controller
Introduction to ember-data
You probably know by now that RestAdapter expects JSON to be in a format that is very unfamiliar to traditional java bean JSON serialization. By default, beans are serialized using embedded records when using a spring @RestController with the Jackson dependency in the classpath.{ "id":1, "active":true, "name":"EmberJava", "createDate":1408162765341, "posts":[ { "id":3, "comment":"Setting up ember to work with java can be confusing!", "createDate":1408162765342 }, { "id":4, "comment":"Hopefully this will make it much easier ...", "createDate":1408162765342 } ], "category": { "id" : 2, "name" : "Programming" } }
However, RestAdapter requires data to be side loaded and have no embedded records. Side loading is where all the data that would normally be embedded; instead gets loaded at the same level as the target. There are good reasons for this which is documented at jsonapi.org.
{ "blog":{ "id":1, "active":true, "name":"EmberJava", "createDate":1408162765341, "posts":[ 3, 4 ], "category":2 }, "posts":[ { "id":3, "comment":"Setting up ember to work with java can be confusing!", "createDate":1408162765342 }, { "id":4, "comment":"Hopefully this will make it much easier ...", "createDate":1408162765342 } ], "category":{ "id":2, "name":"Programming" } }In addition, RestAdapter requires that the JSON be prefixed with the model name which will be singular for a single item and plural for an array as shown above.
This is a little challenging for Jackson but we are able to achieve our desired results through some clever Jackson annotations and some ObjectMapper modifications.
Naming Conventions
The RestAdapter uses sensible naming conventions for accessing resources. This is easy for us to setup in our @RequestMapping as they probably follow a similar pattern to what you are doing already.Action | HTTP Verb | URL |
---|---|---|
Find | GET | /people/123 |
Find All | GET | /people |
Update | PUT | /people/123 |
Create | POST | /people |
Delete | DELETE | /people/123 |
Primitive types and Strings
The good news is that for these types of data nothing needs to be done. Jackson will correctly serialize the data into the correct format for ember-data.Single Entity References
Chances are all your entities are Lazy loaded by default. This is the recommended option and allows the most flexibility. The problem with lazy loading is that once the fetched entity exits the @Transactional service method, you no longer have access to the Hibernate Session in the @RestController. Therefore, when Jackson attempts to serialize your bean you will get the infamous org.hibernate.LazyInitializationException.The good news is that this will not be an issue with ember-data as it only requires the ID of the entity it references. The other good news is that when you do a getId() on an object that is lazy loaded, Hibernate will not actually fetch the object from the database as it already knows this information.
If you are using field access instead of property access on your hibernate entities, it turns out that the above does not stand true. Checkout this post for a solution.As ember requires only the ID of the related entities, we never again need to do a `join fetch` (unless you want to side load which is discussed later) in our HQL queries. We do however need to add a couple of additional annotations to ensure that Jackson will not attempt to serialize other attributes on the referring entity and inadvertently attempt to initialize the entity from the database.
Firstly, we need to tell Jackson the name of the ID in each of our entity classes. This can be achieved by using the @JsonIdentityInfo annotation.
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") public class Blog{ ...
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") public class Category{ ...
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") public class Post { ...
Next, we tell every related entity that Jackson should only serialize the ID value (Jackson knows what that is via the annotation above)
//Blog.java @JsonIdentityReference(alwaysAsId = true) public Category getCategory() { return category; }
//Post.java @JsonIdentityReference(alwaysAsId = true) public Blog getBlog() { return blog; }
That's it, now the Blog will be serialized as follows (Notice that category is only serializing the ID value):
{ "id":1, "active":true, "name":"EmberJava", "createDate":1408162765341, "posts":[ { "id":3, "comment":"Setting up ember to work with java can be confusing!", "createDate":1408162765342, "blog":1 }, { "id":4, "comment":"Hopefully this will make it much easier ...", "createDate":1408162765342, "blog":1 } ], "category":2 }
The final thing we need to do is add a @JsonSetter for these related entities. This is due to the fact that the existing hibernate setter accepts an object, however, ember-data will only be sending along the id of the entity in the JSON payload.
Now Hibernate will use the setter accepting an entity but when Jackson needs to deserialize an incoming object from the front-end, it will use the setter accepting a Long.
Now Hibernate will use the setter accepting an entity but when Jackson needs to deserialize an incoming object from the front-end, it will use the setter accepting a Long.
//Blog.java public void setCategory(Category category) { this.category = category; } @JsonSetter public void setCategory(Long id) { if(id != null) { this.category = new Category(); this.category.setId(id); } }
//Post.java public void setBlog(Blog blog) { this.blog = blog; } @JsonSetter public void setBlog(Long id) { if(id != null) { this.blog = new Blog(); this.blog.setId(id); } }
Lazy Loading Collections using Links
There is a relatively hidden and undocumented feature of ember-data that allows you to load related models via URLs instead of an array of IDs (see here). This is particularly convenient for Hibernate as we generally don't want to `join fetch` all our collections before serializing it to JSON.RAILS developers seem to be able to easily serialize ids of their collection inline to the payload. This is not so easy with Hibernate as it would mean all our collections would have to be initialized which may be inconvenient.
{ "blog":{ "id":1, "active":true, "name":"EmberJava", "createDate":1408162765341, "posts":[ 3, 4 ], "category":2 } }
We can instead use a links collection with a relative path to a controller mapping based off the fetched object mapping.
{ "blog":{ "id":1, "active":true, "name":"EmberJava", "createDate":1408162765341, "category":2, "links" : { "posts" : "posts" } } }
So now, ember-data will fetch related posts from the following URL (You will need to create a controller mapping if you want any results)
/blogs/1/posts
First create a new interface as follows:
public interface EmberLinks { ConcurrentMapgetLinks(); }
Use the Jackson @JsonIgnore method on all your collections
//Blog.java @JsonIgnore public ListgetPosts() { return posts; }
//Category.java @JsonIgnore public ListgetBlogs() { return blogs; }
Now implement the Ember interface getLinks() method
public class Blog implements EmberLinks { @Override public ConcurrentMapgetLinks() { ConcurrentMap links = new ConcurrentHashMap (); links.put("posts", "posts"); return links; }
public class Category implements EmberLinks { @Override public ConcurrentMapgetLinks() { ConcurrentMap links = new ConcurrentHashMap (); links.put("blogs", "blogs"); return links; }
Now your json looks like this:
{ "id":1, "active":true, "name":"EmberJava", "createDate":1408170544214, "links":{ "posts":"posts" }, "category":2 }
java.util.Date
This requires a special mention due the fact that ember-data has a date attribute:DS.attr('date')
The API can actually accept a date in milliseconds (1408170544214) or as an ISO string (2014-08-15T11:38:09Z). However, when ember-data serializes a date, it will return an ISO string. Therefore, you need to inform Jackson of that fact that Dates will be returned in ISO format. Given the ISO string is much more user friendly to work with, I serialize/deserialize all my dates in this format.
This can be achieved by adding the following modified ObjectMapper to your project:
public class EmberAwareObjectMapper extends ObjectMapper { public EmberAwareObjectMapper() { //indent the json output so it is easier to read configure(SerializationFeature.INDENT_OUTPUT, true); //Wite/Read dates as ISO Strings configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); } }
You now need to update your spring configuration to use this new ObjectMapper. In your servlet-context.xml file, update as follows:
<annotation-driven> <message-converters> <!-- Use the EmberAwareObjectMapper mapper instead of the default --> <beans:bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"> <beans:property name="objectMapper"> <beans:bean class="au.com.emberspring.jackson.EmberAwareObjectMapper"> </beans:bean></beans:property> </beans:bean> </message-converters> </annotation-driven>
Now the generated JSON looks like:
{ "id":1, "active":true, "name":"EmberJava", "links":{ "posts":"posts" }, "category":2, "createDate":"2014-08-16T21:30:03+1000" }
Side Loading Records from your controllers
We now have almost everything setup, however, we are missing the root element and have not yet covered side loading. To achieve this I created a custom side loader you can use with the ability to side load entities and add meta data.ember-data supports a meta element in the JSON payload. This can be used to store information that relates to the request but is not part of the entity itself. Pagination information is a classic example. See here for more information about the metadata.
Java Based Ember Side Loader
import java.util.Collection; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import javax.validation.constraints.NotNull; import org.atteo.evo.inflector.English; import org.springframework.util.Assert; import com.google.common.base.CaseFormat; @SuppressWarnings("serial") public final class EmberModel extends ConcurrentHashMap<String, Object> { private EmberModel() { super(); //Must use the builder } // Factory method private static EmberModel createEmberModel() { return new EmberModel(); } public static class Builder implements au.com.patrick.elm.common.patterns.Builder<EmberModel> { private final ConcurrentMap<String, Object> sideLoadedItems = new ConcurrentHashMap<String, Object>(); private final ConcurrentMap<String, Object> metaData = new ConcurrentHashMap<String, Object>(); public Builder(final Object entity) { Assert.notNull(entity); sideLoad(entity); } public Builder(final String rootName, final Object entity) { Assert.notNull(entity); sideLoad(rootName, entity); } public Builder(final Class<?> clazz, final Collection<?> entities) { Assert.notNull(entities); sideLoad(clazz, entities); } public Builder(final String rootName, final Collection<?> entities) { Assert.notNull(entities); sideLoad(rootName, entities); } public Builder(final String rootName, final Collection<?> entities, final boolean isRootNameAlreadyPlural) { Assert.notNull(entities); sideLoad(rootName, entities, isRootNameAlreadyPlural); } public Builder addMeta(@NotNull final String key, final Object value) { if (value != null) { metaData.put(key, value); } return this; } //Internal use only private Builder sideLoad(final Object entity) { if (entity != null) { sideLoadedItems.put(getSingularName(entity.getClass()), entity); } return this; } //Internal use only private Builder sideLoad(final String rootName, final Object entity) { if (entity != null) { sideLoadedItems.put(rootName, entity); } return this; } public Builder sideLoad(final Class<?> clazz, final Collection<?> entities) { if (entities != null) { sideLoadedItems.put(getPluralName(clazz), entities); } return this; } public Builder sideLoad(final String rootName, final Collection<?> entities) { if (entities != null) { sideLoadedItems.put(English.plural(rootName), entities); } return this; } public Builder sideLoad(final String rootName, final Collection<?> entities, final boolean isRootAlreadyPlural) { if (entities != null) { if (isRootAlreadyPlural) { sideLoadedItems.put(rootName, entities); } else { sideLoadedItems.put(English.plural(rootName), entities); } } return this; } private String getSingularName(final Class<?> clazz) { Assert.notNull(clazz); return CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_CAMEL, clazz.getSimpleName()); } private String getPluralName(final Class<?> clazz) { return English.plural(getSingularName(clazz)); } @Override public EmberModel build() { if (metaData.size() > 0) { sideLoadedItems.put("meta", metaData); } final EmberModel sideLoader = createEmberModel(); sideLoader.putAll(sideLoadedItems); return sideLoader; } } }Once created, we can now start returning the EmberModels from our controllers.
@RestController @RequestMapping("blogs") public class BlogController { private final transient BlogService blogService; @Autowired public BlogController(final BlogService blogService) { this.blogService = blogService; } @RequestMapping( value = "{id}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.OK) public EmberModel getBlog(@PathVariable("id") final long blogId) { Blog blog = blogService.getBlog<Blog>(blogId); return new EmberModel.Builder(blog) .sideLoad(Post.class, blog.getPosts()) .addMeta("totalRecords", 100) .build(); } @RequestMapping( method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.OK) public EmberModel getBlogs() { Listblogs = blogService.getBlogs(); return new EmberModel.Builder<Blog>(Blog.class, blogs) .addMeta("totalRecords", blogs.size()) .build(); } }
When a request is made to /blogs/1, we get the following output. Notice we have side loaded all the blogs posts.
{ "blog":{ "id":1, "active":true, "name":"EmberJava", "links":{ "posts":"posts" }, "category":2, "createDate":"2014-08-16T21:30:03+1000" }, "posts":[ { "id":3, "comment":"Setting up ember to work with java can be confusing!", "blog":1, "createDate":"2014-08-16T21:30:03+1000" }, { "id":4, "comment":"Hopefully this will make it much easier ...", "blog":1, "createDate":"2014-08-16T21:30:03+1000" } ] }
When we go to /blogs we get the following, notice that we have instructed the sideLoader to include meta information about the total number of posts.
.addMeta("totalRecords", blogs.size())
{ "blogs":[ { "id":1, "active":true, "name":"EmberJava", "links":{ "posts":"posts" }, "category":2, "createDate":"2014-08-16T21:47:54+1000" }, { "id":2, "active":true, "name":"EmberJava", "links":{ "posts":"posts" }, "category":2, "createDate":"2014-08-16T21:47:54+1000" }, { "id":3, "active":true, "name":"EmberJava", "links":{ "posts":"posts" }, "category":2, "createDate":"2014-08-16T21:47:54+1000" } ], "meta":{ "totalRecords":3 } }
Part 2 - Sending JSON to a Spring Controller.
As far as receiving records from the server we are done. However, saving records will not currently work.There are two issues:
- Ember is sending the root element to the server which Jackson does not recognise.
- Ember does not send the ID in the payload (which we can workaround by setting the id back on the object in the controller but is inconvenient).
//Spring Serializer //@Abstract App.SpringSerializer = DS.RESTSerializer.extend({ serializeIntoHash: function(hash, type, record, options) { var serialized = this.serialize(record, options); //Include the id in the payload //Jackson was complaining when it received a null id ... serialized.id = record.id ? record.id : 0; //remove the root element Ember.merge(hash, serialized); } });
//Application Serializer App.ApplicationSerializer = App.SpringSerializer.extend();
You should now be able to save ember records to the server.
java.util.Date in the Query String
As described above, dates will be sent to the server in ISO format. Because of our existing ObjectMapper configuration (described above) everything should now work for hibernate entities; dates will be correctly converted from ISO format into a java.util.Date. Sometimes though, you will need to supply a date as part of a query string and not part of the Ember model. An example would be retrieving blogs from the server with a createDate between two different values.You can do this from javascript using the toISOString() function:
var model = this.modelFor('search'); this.store.find('blog', { sortColumn : model.get('sortColumn'), sortDirection : model.get('sortDirection'), fromDate : model.get('fromDate') ? model.get('fromDate').toISOString() : null, toDate: model.get('toDate') ? model.get('toDate').toISOString() : null, });
In the spring controller, you can accept the date and bind it directly to a java.util.Date using the @DateTimeFormat annotation:
@RequestMapping( method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE, params = {"sortColumn", "sortDirection"}) @ResponseStatus(HttpStatus.OK) public EmberModel doSearch( @RequestParam(value = "sortColumn", required = false) final String sortColumn, @RequestParam(value = "sortDirection", required = false) final String sortDirection, @DateTimeFormat(iso = ISO.DATE_TIME) @RequestParam(value = "fromDate" required = false) final Date fromDate, @DateTimeFormat(iso = ISO.DATE_TIME) @RequestParam(value = "toDate", required = false) final Date toDate, ) { ...
Saving with embedded records.
There are times when it is necessary to embed records into the response due to business rules. Say for example there is a business rule for a blog to have at least one post before it can be saved. With the current configuration you would need to first save the blog and then later save the posts in a separate request thus breaking the business rule.There is a way to embed records using the DS.EmbeddedRecordsMixin. The DS.EmbeddedRecordsMixin works on a per model basis.
We want to embed all the posts into the serialized blog that is sent to the server. We can do that like this:
//Blog Serializer App.BlogSerializer = App.SpringSerializer.extend(DS.EmbeddedRecordsMixin, { //Force embedding the posts array into the payload to the server attrs: { posts: { serialize: 'records' } } });
The attrs hash allows you to define the fields that will be embedded. Check out this link for more information.
When you save the blog from ember, the JSON will now format as follows with the posts array embedded into the payload:
{ "active":true, "name":"EmberJs", "createDate":"2014-08-23T01:04:25.035Z", "posts":[ { "id":"11", "comment":"{async : true} on your ember model relationships is necessary to get 'links' working!", "createDate":"2014-08-23T01:04:25.337Z", "blog":"1" }, { "id":"12", "comment":"DS.attr('date') an accepts ISO or millisecond format but is serialized in ISO format only.", "createDate":"2014-08-23T01:04:25.337Z", "blog":"1" } ], "id":"1" }
Now that that client side is embedding posts properly, we need to update the backend
We first need to tell the blogs posts array that it can start accepting embedded records. This existing @JsonIgnore annotation prevents the setter from executing during both serialization and deserialization. We can override this behaviour by adding the @JsonSetter annotion to the setPosts() method as follows:
//Blog.java @JsonIgnore public ListgetPosts() { return posts; } @JsonSetter public void setPosts(List posts) { this.posts = posts; }
Now the posts will be ignored during serialization but will be included during deserialization.
Finally, add the following mapping to the BlogController:
//BlogController.java @RequestMapping( value = "{id}", method = RequestMethod.PUT, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.OK) public EmberModel saveBlog(@RequestBody Blog blog, @PathVariable("id") final long blogId) { return new EmberModel.Builder(blog) .addMeta("serverSaid", String.format("Received PUT request for Blog(%d) successfully with %d posts", blogId, blog.getPosts().size())) .build(); }
If you put a breakpoint on the return statement, you will see that blog now has a full array of posts available for processing.
Hi Jack, very nice article, it helped me a lot in understanding the logic of REST Adapter. I need a clarification, when i downloaded the project and gave a try i couldn't see any information that we set in our Spring controller in my index.html. Are i am misunderstood any item.
ReplyDeleteHi Sathish, if I follow your question correctly, RestAdapter automatically knows how your server URLs are setup by using common conventions. See the section on "Naming Conventions" at the top of the article which is using an Ember model called "Person". You need to follow this naming convention in your @RequestMappings.
DeleteSo if you have an Ember model named "Dog" and you call the save() method on it, it will either send a POST request to /dogs (if it was a new model) or a PUT request to /dogs/23 if it was an existing model with an ID of 23.
DeleteThanks for the response Jack, now I got you and able to run the sample. Once again i thank you for this blog which clearly explained me about ember data REST Adapter.
DeleteJack, Sorry for disturbing you again. In my example all the action are working fine except Ember delete [destroyRecord()], my data gets removed from my DS store and the control too reached my REST URL but it didn’t get into my business logic and throws server error (415 - Unmatched ContentType).
DeleteWhen I verified my Network flow data in browser developer tool (F12), I seen all my actions have JSON type, except DELETE. DELETE’s type is in HTML. Can you tell me how can I handle this, I need to delete my record in database.
Can you post your spring delete method?
DeleteCode in my Ember JS:
DeleteApp.UserRoute = Ember.Route.extend({
model: function() {
return this.store.find('m2MUser');
},
actions : {
deleteUser : function(user) {
this.store.find('m2MUser', user.id).then(function (user) {
user.destroyRecord(); // => DELETE to /posts/2
user.save();
user.commit();
// user.deleteRecord();
// user.get('isDeleted'); // => true
// user.save(); // => DELETE to /posts/1
});
.......
.......
});
Spring Code
@RequestMapping(
value = "{id}",
method = RequestMethod.DELETE,
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.OK)
public String deleBlog(@PathVariable("id") final long blogId) {
......
......
System.out.println(" Added blog >>>> " + blogId);
return "";
}
Remove:
Deleteconsumes = MediaType.APPLICATION_JSON_VALUE
from the controller because it is not a JSON request, it is just a DELETE to /posts/1, no JSON at all is being sent to the server.
This comment has been removed by the author.
ReplyDeleteThank you!. This article had been really helpful for me. Mainly piece where the model object is transformed to JSON format that is easily rendered at the front end. Especially we were working on a project which uses Ember + Rest.
ReplyDeleteOne question I have is the usage of ConcurrentHashMap in the EmberModel object ?. What was the reason you used a ConcurrentHashMap ?. Do you think that RestController is not thread safe or any other reasons ?
No good reason for ConcurrentHashMap. I started using it instead of HashMap when I was getting PMD warnings in eclipse saying that I should be using ConcurrentHashMap instead of HashMap and now it is just habit. You can change to to a regular Map without any issues.
DeleteIf you have this code running, it seems to me you wouldn't see a problem with the Map synchronization unless people were posting to the blog at the same time you were reading it. You could probably prove it out with a unit test. You might get a ConcurrentModificationException during reading while someone else writes. This would occur while iterating the posts.
DeleteExcellent article! I'm working on a project with nearly the exact same setup. HUGE kudos to you for putting this together!
ReplyDeleteYou added a header saying this solution no longer works with ember-data 2. What would you recommend as a replacement?
ReplyDeleteIt still work with ember 2.0 if you extend DS.RESTAdapter.
DeleteHi Jack,
ReplyDeleteCan you please help me to run this application ?
Yes, let me know what you are having trouble with. Is it the github code?
DeleteHi, is this article still relevant for today 4 years later? I read the whole thing, and if you know a better way to do that could you please direct me there? Thanks
ReplyDeleteIt is nice blog Thank you provide important information and I am searching for the same information to save my time Ruby on Rails Online Training
ReplyDeleteno deposit bonus forex 2021 - takipçi satın al - takipçi satın al - takipçi satın al - takipcialdim.com/tiktok-takipci-satin-al/ - instagram beğeni satın al - instagram beğeni satın al - google haritalara yer ekleme - btcturk - tiktok izlenme satın al - sms onay - youtube izlenme satın al - google haritalara yer ekleme - no deposit bonus forex 2021 - tiktok jeton hilesi - tiktok beğeni satın al - binance - takipçi satın al - uc satın al - finanspedia.com - sms onay - sms onay - tiktok takipçi satın al - tiktok beğeni satın al - twitter takipçi satın al - trend topic satın al - youtube abone satın al - instagram beğeni satın al - tiktok beğeni satın al - twitter takipçi satın al - trend topic satın al - youtube abone satın al - instagram beğeni satın al - tiktok takipçi satın al - tiktok beğeni satın al - twitter takipçi satın al - trend topic satın al - youtube abone satın al - instagram beğeni satın al - perde modelleri - instagram takipçi satın al - instagram takipçi satın al - cami avizesi - marsbahis
ReplyDeletemmorpg
ReplyDeleteinstagram takipçi satın al
tiktok jeton hilesi
Tiktok Jeton Hilesi
antalya saç ekimi
referans kimliği nedir
İnstagram Takipçi Satın Al
Mt2 pvp serverler
İnstagram takipci satin al
smm panel
ReplyDeletesmm panel
İş İlanları Blog
instagram takipçi satın al
hirdavatciburada.com
beyazesyateknikservisi.com.tr
servis
tiktok jeton hilesi
The DNV Equipment in light of Delhi production and provider. This organization upvc equipment providers in india and reliably serving for that large number of individuals who in a real sense need to get the best out of the UPVC embellishments best upvc doors and windows delhi providers on the lookout. The Metalkraft Window Adornments in light of Hyderabad assembling and provider. This organization providers upvc equipment in india. Fates Equipment gives you a total scope of UPVC entryways and windows equipment. This organization in view of delhi provider.
ReplyDeleteElevate your online presence with powerful singapore dedicated server. Experience unmatched performance and reliability for your business needs.
ReplyDeleteEscape to a luxurious best resort in jaipur, where royal elegance meets modern comfort. Enjoy world-class amenities, serene landscapes, and unforgettable experiences.
ReplyDelete